this article was initially written on 16JAN2025, but uploaded 29MAY2026.

event-driven programming considered harmful

introduction

there’s a certain Pet Peeve i happen to have with event-driven programming as a whole. a lot of javascript libraries especially (looking at you, Literally All Of NodeJS’s API) happen to use a paradigm which i dub Event-Driven Programming, and i will explain what i mean by that, but first, i need to take a detour into something that seems fairly unrelated:

structured code

this essay’s title happens to be a reference to dijkstra’s famous letter Go To Statement Considered Harmful[0] (this was before the keyword “goto” became fully lexicalized in programmer circles), and that isn’t just because it’s a kind of funny way to express disapproval of a concept in general, but also because i’m arguing against something fairly similar.

the argument against GOTO relies on a simple idea: how much information does one need to, unambiguously, determine where in the flow of execution a program has reached. and more critically: how does that information relate to the program itself and its structure. (to define “location within the flow of execution” more rigorously, imagine that one’s rerunning the program from the start; what information does one need to successfully set a breakpoint in the program?)

when there are precisely zero control flow structures, this is fairly easy. one just points to the statement that is being executed. this is one integer, and is as directly related to the source code of the program as much as it is related to the execution of the program.

when we introduce IF ELSE THEN (conditionals), nothing changes. the execution of the program is, still, entirely dependent on which statement is being executed at the moment.

: ; (procedures/subroutines/functions) throw a wrench into this. now, one has to also keep track of the return stack. this means one’s statement numbers evolve into stacks of statement numbers, but, that’s still fairly reasonable, and it maps onto the source code of the program a-ok.

BEGIN WHILE REPEAT (loops) also make this a bit more messy, but one can just count the number of iterations done within a loop. this requires adding two new elements to the stack (iteration count & statement number in the loop body), too, because loops can be nested, however, this is not too bad.

LABEL GOTO throws a wrench into this system, but this time it actually causes severe damage. because any GOTO can go to any LABEL, often without even any additional structure, all hopes of having a structured index for program execution are crushed. the only solution that remains is to count the number of statements executed from the start of the program, which is as correct as it is entirely meaningless.

this is why GOTO statements, in general, suck. they make program control flow hard to reason about, which is why we have banished them to the realm of hardcore assembly fanatics.

i jest, somewhat. LABEL GOTO still has it’s place in relatively high level langauges, such as C[1]. in C, LABEL GOTO is a fairly intuitive & nonintrusive way to handle error cases[2]:

parse_binary(s)
  char s[];
{
  register n = 0;
  register char *p = s;

  while (*p) {
    if (*p == '0') {
      n <<= 1;
    } else if (*p == '1') {
      n <<= 1;
      n |= 1;
    } else {
      goto fail;   /* goto stmt */
    }
  }

  return n;

fail:   /* used for the failure case (invalid binary) */
  /* error reporting foo... */
  return -1;
}
when error cases get incredibly tangled in several layers of loops and conditionals, this can serve to cut through them.

as a matter of fact, structured code is turing-complete. unstructured code (here meaning “code with LABEL GOTOs in it”) happens to also be turing-complete. this means that any structured program can be translated into an unstructured program (trivially), and any unstructured program can be translated into a structured program (not as trivial). the argument is as follows:

take each statement, and put it in a control flow graph. for instance, one might have:

  a = 4;
  b = 3;
foo:
  ++a;
  if (b) goto bar;
  c = 3;
  ++b;
  goto baz;
bar:
  c = 0;
baz:
  a += b;
  a += c;
firstly, we can rewrite this to use labels on each and every statement:
s0: a = 4;
s1: b = 3;
s2: ++a;
s3: if (b) goto s7;
s4: c = 3;
s5: ++b;
s6: goto s8;
s7: c = 0;
s8: a += b;
s9: a += c;
every statement can now end with a GOTO of some sort:
s0: a = 4; goto s1;
s1: b = 3; goto s2;
s2: ++a; goto s3;
s3: if (b) goto s7; else goto s4;
s4: c = 3; goto s5;
s5: ++b; goto s6;
s6: goto s8;
s7: c = 0; goto s8;
s8: a += b; goto s9;
s9: a += c; goto s10;
next, we can emulate this via a BEGIN CASE ( ... ) END-CASE AGAIN structure, with a variable to tell us which statement we’re executing:
register state = 0;

for (;;) {
       if (state == 0) { a = 4; state = 1; }
  else if (state == 1) { b = 3; state = 2; }
  else if (state == 2) { ++a; state = 3; }
  else if (state == 3) { if (b) state = 7;
                         else   state = 4; }
  else if (state == 4) { c = 3; state = 5; }
  else if (state == 5) { ++b; state = 6; }
  else if (state == 6) { state = 8; }
  else if (state == 7) { c = 0; state = 8; }
  else if (state == 8) { a += b; state = 9; }
  else if (state == 9) { a += c; state = 10; }
}
now, there’s no denying that this is a rather ugly translation, and that it’s possible to translate this code significantly more nicely, however, this is a bullet-proof, systematic way to translate an arbitrary unstructured program into structured form.

we shall make note of the hallmark of “unstructured code google-translated into a structured programming language”: a set of IFs that execute multiple times (usually, but not necessarily, by means of a loop) and that check equality with some sort of variable such that the bodies of each of those IFs change the variable as their last operation.

we are now prepared to venture back into event-driven programming.

event-driven programming

what i mean by “event-driven programming” is the particular methodology of programs that manifest whenever one has asynchronous (or similar) operations.

Concurrency has been with us for so long that, as software developers, we’ve internalized its primary constraint: global variables and states should be avoided.

To some of us, avoiding these became a second nature, so much that we don't even see the complexity associated with these habits anymore.

Naively, we underestimate that weight being lifted off because we forget that compounding effects are exponential. With practice, I can tell you: it’s much more than you might think.

vdupras, DuskOS

this is an evergreen statement.

the examples of event-driven programming causing insanity are, in fact, too numerous for me to universally list them off here, so i’ll be fixating on only a few examples:

requestAnimationFrame

requestAnimationFrame in javascript is a function that takes in a callback function, and calls it back once the browser is done drawing the current webpage to the screen. it’s a one-shot function, meaning that the callback is only called back once, and then discarded. seems like a reasonable design decision, does it not?

let’s just pull up the MDN web docs for requestAnimationFrame and oh god what the hell is that.

let zero;
requestAnimationFrame(firstFrame);
function firstFrame(timestamp) {
  zero = timestamp;
  animate(timestamp);
}
function animate(timestamp) {
  const value = (timestamp - zero) / duration;
  if (value < 1) {
    element.style.opacity = value;
    requestAnimationFrame((t) => animate(t));
  } else element.style.opacity = 1;
}

okay, i may be a functional programming advocate, but this is too many functions for me. firstly, (t) => animate(t) is a laughable lambda. but what is slightly more terrifying is what that lambda is doing. it’s a function that is recursing via a callback. even more annoyingly, it’s a callback-aided tail recursion. javascript is an imperative language. the fact that this function is forced to tail recurse is a sign that something has gone wrong.

if you’re eagle-eyed, you may have already spotted where this argument is going. there’s an if statement that checks some sort of value (appropriately yet completely uselessly named value), that if statement runs in a loop (tail-recursion), and it conceptually mutates that value (the way it sidesteps having to actually mutate it is by keeping track of zero).this is, in fact, unstructured code. even better, one of the branches of the if statement actually hops out of the tail-recursion. you know what we call goto statement backward chained with an if that skips the backwards goto? a while loop. this entire code snippet, composed of two functions and an uninitialized global variable, is a deconstructed, functional, while loop.

we can, in fact, rewrite this to just be a normal while loop, and the code becomes a great deal simpler:

let start = nextFrame();
let value;
while ((now = nextFrame()) < start + duration)
  element.style.opacity = (now - start) / duration;
element.style.opacity = 1;

not only that, but it’s now actually structured. the only trouble is that it’s synchronous. we can fix that by meticulously adding the asynchronicity back. the issue with callbacks is that they lead to callback hell, and the solution to callback hell tends to either be to factorize everything heavily (a good solution, but not always possible: one can’t slice a function in half arbitrarily and expect to be able to give both halves good names!), or... to use the feature in javascript specifically built to combat this. oh, hey, await!

function nextFrame() {
  return new Promise(function (resolve, reject) {
    try {
      requestAnimationFrame(resolve);
    } catch (error) {
      reject(error);
    }
  });
}

let start = await nextFrame();
let value;
while ((now = await nextFrame()) < start + duration)
  element.style.opacity = (now - start) / duration;
element.style.opacity = 1;

another issue with requestAnimationFrame is it returns the current timestamp instead of a deltatime of some sort. this isn’t terrible (and makes sense if one has several different functions all hooking into requestAnimationFrame independently), but complicates code fairly often, so you might want to consider making that change if you plan to use this.

setTimeout

this has the same shtick:

function keepTryingX() {
  if (!tryX()) {
    /* failure case */
    setTimeout(keepTryingX, 10000);
  }
}

this is another while loop. the reason why these examples are while loops specifically is because those are what makes it blatantly obvious. most of the time, they’re just endlessly chained together in callback hell, but a while loop forces one to create a wholly new function.

function sleep(time) {
  return new Promise(function (resolve, reject) {
    try {
      setTimeout(resolve, time);
    } catch (error) {
      reject(error);
    }
  };
}

while (!tryX())
  await sleep(1000);

WebSocket

now we dip into the example that drove me to making this article in the first place. websockets. now, i have very, very mixed opinions on websockets. this is one of the negative ones.

say i want to implement a fairly simple protocol. i will describe it in the most natural way that it comes to my mind: the client will connect to the server, and send the server SYN. the server will then respond with ACK, followed by SYN. the client will respond to this with ACK. the server will then periodically send PING to the client, and the client shall respond with PONG. that’s it. that’s the whole protocol. notice how i described it imperatively: this happens, then this happens, then this happens. let’s see how one would implement this over websockets...

let socket = new WebSocket("ws://localhost:8080");
let socketState;

socket.addEventListener("open", function (event) {
  socketState = 0;
  socket.send("SYN");
});

socket.addEventListener("message", function (event) {
  if (socketState == 0) {
    if (event.data == "ACK") {
      socketState = 1;
    }
  } else if (socketState == 1) {
    if (event.data == "SYN") {
      socket.send("ACK");
      socketState = 2;
    }
  } else if (socketState == 2) {
    if (event.data == "PING") {
      socket.send("PONG");
    }
  }
});

what do we see here? ...a set of IFs that execute multiple times (by means of an event listener), and that check equality with some sort of variable (socketState) such that the bodies of each of those IFs change the variable as their last operation.

this is a deconstructed structured program.

that’s the trouble with event-driven programming. it’s not just similar to an unstructured program, it is an unstructured program. it is, definitionally, what happens when one tries to shoehorn an unstructured program into a structured programming language, and all the benefits of switching to structured programming apply here too! notice how i described the protocol. i described it incredibly imperatively, i never said “the client then waits for an incoming message. if it’s in the [foo] state and recieves a SYN, it sends an ACK and goes into the [bar] state.”. i said “it waits for SYN, then sends ACK

we can fix this through a short series of basic improvements: firstly, i need to note that the exact step that causes code to be “unstructured in nature” is not the state variable itself, but the repeated IF checks. even more specifically, it’s the repetition that’s the problem. so, let’s eliminate the repetition by forcing each event handler to execute only once:

function listen(socket, eventType, handler) {
  function internal(event) {
    socket.removeEventListener(eventType, internal);
    handler(event);
  }
  socket.addEventListener(eventType, internal);
}


listen(socket, "open", function (event) {
  socket.send("SYN");
  listen(socket, "message", function (event) {
    if (event.data == "ACK") {
      listen(socket, "message", function (event) {
        if (event.data == "SYN") {
          socket.send("ACK");
          function main() {
            listen(socket, "message", function (event) {
              if (event.data == "PING") {
                socket.send("PONG");
                main();
              }
            });
          }

          main();
        }
      });
    }
  });
});

now we’re getting somewhere. however, the “somewhere” appears to be Callback Hell. this is accentuated by the awkwardly named main function.

...aha! await!

function listen(socket, eventType) {
  return new Promise(function (resolve, reject) {
    function handler(event) {
      socket.removeEventListener("error", reject);
      resolve(event);
    }
    socket.addEventListener("error", reject);
    socket.addEventListener(eventType, handler);
  };
}

await listen(socket, "open");
socket.send("SYN");
if ((await listen(socket, "message")).data == "ACK") {
  if ((await listen(socket, "message")).data == "SYN") {
    socket.send("ACK");
    while ((await listen(socket, "message")).data == "PING") {
      socket.send("PONG");
    }
  }
}

the rest of the awkward code can be handled by just using an assert function:

class AssertionFail extends Error {
  constructor() {
    super("assertion failed");
    this.name = "AssertionFail";
  }
}

function assert(condition) {
  if (!condition) {
    throw new AssertionFail();
  }
}

async function expect(socket, data) {
  let packet = await listen(socket, "message"); 
  assert(packet.data == data);
  return packet;
}

await listen(socket, "open");
socket.send("SYN");
await expect(socket, "ACK");
await expect(socket, "SYN");
socket.send("ACK");
while (await expect(socket, "PING")) {
  socket.send("PONG");
}

that is the problem with event-driven code.

common follow-up arguments

“what about handling button input (or an animation controller) in a video game, for instance. do you really suggest that i remodel that imperatively?”

absolutely not. that’s a fully justified usage of unstructured code. it handles state in a manner that is much more poorly described imperatively. specifically, with the websocket example, you can imagine each state as a circle, with states containing the states they lead to (in a kind of topological sort). “if one has reached state 2, one had to have been at state 1 at some point in the past.” the back-edges are then represented by various looping constructs.

player input and animators, uh, don’t work this way! the circles are not remotely sorted like that, and are intersecting eachother arbitrarily (if the player is moving and grounded, perform walking animation, but if the player is not moving and grounded, perform idle animation, but if the player is not grounded, perform falling animation). that said, it’s still a bad idea to represent that as a naïve state machine. Animator Hell happens for the same reasons that Callback Hell happens. i’d suggest a system like aarthificial’s reanimation instead (where the state machine is replaced by a decision tree). player input does similar stuff.

“this is impractical, no library uses this interface.”

function curry(offender, ...args) {
  return new Promise(function (resolve, reject) {
    try {
      offender(...args, resolve);
    } catch (error) {
      reject(error);
    }
  });
}

EventTarget.prototype.waitFor = function (eventType) {
  return new Promise(function (resolve, reject) {
    try {
      this.addEventListener(eventType, resolve);
    } catch (error) {
      reject(error);
    }
  })
}; 

now almost everything uses it.

“it’s not that bad, this is an overreaction.”

fun fact: some people still write unstructured code, and by some people, i mean a surprising number of them![3]

“why?”

yes.

“what”

Y e s .

footnotes & references

  1. Go To Statement Considered Harmful, Edsger W. Dijkstra. [back]
  2. new “spot the machine code programmer” bingo space: they refer to The C Programming Language as a “high level language”. [back]
  3. unless you’re some absolute rookie that has ZERO clue what they are doing. you know. like the folks at Apple. the gigantic tech conglomerate. [back]
  4. hi. i’m a sizecoder. i still try to avoid unstructured code at all costs outside of hardcore byte shaving. also, if you write event-driven code, you’re one of them. [back]

this article is one of my submissions to The Writing Gaggle! check them out, they’re cool.

the brainmade dot org logo.