Sam DeSota



Javascript debuggers are broken, and it's our fault.

21 Apr 2019 »

Debuggers are pretty vital part of my development workflow today, but I was slow to adopt it. While I now build apps with “Pause on exception” turned on in Chrome Dev Tools and drop debugger statements all over my code base while building out the initial scaffold of features and apps, for my first 2 - 3 years of work with Javascript I used a combination of console.log statements and commenting out lines. I was vaguely familiar with the debugger, but it seemed awkward at first use and console.log had served me just fine thus far. Eventually Chrome Dev Tools become easy enough to use for me to really understand the power of the debugger for inspecting how my code executes.

Today, in my own workflow the debugger is most useful as sort of marker to say “I need to write some code here, but I’m working on something else right now and don’t want to interrupt my train of thought, so when my code eventually hits this debugger statement I’ll figure out what needs to be written”. As it’s name might suggest, I also use it to inspect local scopes to remove bugs from code, but with modern workflows that part of the debugger has gotten harder to use.

The Javascript community keeps on building more powerful abstractions like React, now with hooks that allow you to create code that looks synchronous but is really dynamic and reactive:

const NewTweetsFromAuthor = ({ author ) => {
    const tweets = useLatestTweets({ count: 5, filter: { author }})

    if (tweets == null) {
      return <Loading />
    }

    return <TweetList>
      {tweets.map(tweet => <Tweet key={tweet.id} tweet={tweet} />)}
    </TweetList>
}

This is wonderfully elegent, but what happens when we try to navigate into <Tweet /> via the debugger?

Hmm… yeah this is definitely not where I wanted to be. Basically:

This function returns a object that represents the eventual execution of my actual “function” or component I want to navigate into, but before that is executed and I can inspect the execution of the <Tweet /> component this value and the whole JSX tree needs to be returned from my NewTweetsFromAuthor component above and diffed against the current state by react. My best bet for navigating into the <Tweet /> component is to place a break point at the start of the Tweet function and hope react doesn’t render any other <Tweet /> components before it get’s to the invocation I wanted to step into.

Yuck.. Even for code that isn’t quite so far abstracted as React, layers of function calls through libraries makes using the debugger more and more cumbersome as we use higher-level abstractions. Even further, for code that does behave well with debuggers, there’s a whole dimension of debug information that we loose with the status quo of “Call stack and forward” debug approach. Let me demonstrate… consider this snippet similar to something I wrote today for a drag and drop layout builder:

const getWidgetPosition = (dragMonitor, gridElement) => {
  const rect = gridElement.getBoundingClientRect();
  const position = dragMonitor.getClientOffset();
  return {
    x: postion.x - rect.x,
    y: position.y - rect.y
  }
}

const makeWidget = (dragMonitor, gridElement) => {
  const position = getWidgetPosition(dragMonitor, gridElement)
  const dimensions = getWidgetDimensions(dragMonitor.getItem())

  ...
}

const onDrop = (monitor, props, component) => (
  const widget = makeWidget(monitor, component.gridRef.current)
  onAddWidget(widget)
}

const onAddWidget = () => {
  debugger
  ...
}

Don’t worry about the details here, I’ve simplified the actual bug for demonstration, but these functions were distributed across a couple files and react components using the React DnD library. The code above has a bug (inside getWidgetPosition()), when dropping the widget onto the canvas, it didn’t drop where expected but instead the widget disappeared from the canvas :(. I dropped a debugger statement under onAddWidget() to see where the issue was coming from, then started to walk up the call stack:

I’ve highlighted the code path I can walk up in red. Unfortunately, once I visit onDrop, I can see that widget has a position with an unexpectedly negative value, but I can’t walk into the call of the makeWidget() function with the debugger, I’d have to reverse time or the V8 engine would have to store all historical scopes and call stacks of the entire program.. one hell of a memory leak.

I can’t navigate to any of the code in gray, it’s state has been garbage collected. My only option is to add a new breakpoint inside getWidgetPosition(), restart my whole app and re-setup the test condition. While in this example, you could probably figure out the bug by directly looking at getWidgetPosition() (just reverse the subtraction of the positions), unless you’re a superhuman I’m sure you’ve had the head banging experience of trying to navigate more complex call stacks through async events and state changes.

To add to our list of feedback for Javascript debuggers here’s a couple more frustrations you may be familiar with:

  • Buggy source maps resulting from bundling and transpilation tools like Webpack and Babel that cause placing break points to be unreliable, and defined names declared undefined
  • Closure optimization, meaning we can’t access names that aren’t directly referenced in the current function scope
  • Try stepping through a series of try and catch blocks… uhg.

We can’t really blame the Chrome Dev Tools team for not giving us a better debugger with APIs to declare how to navigate the code base. While dev tools have already taken initiative to work well with with native abstractions like Promises and async await, technical limitations mean we’ve nearly reached the end of what we can do with Javascript debuggers. For something like a React component “application” or call, there’s no direct relationship to a real function call you could step into because React chooses whether or not to actually call the <Tweet /> component based on a diff from the last call. There’s real memory and performance limitations that make storing every variable and function your program creates impractical.

This represents a greater problem then debugging tools than just supporting more abstractions and better debugging UX, to get better development tooling we need a better core language that focuses on supporting this sort of reactive code as a primitive.

In my next post I want to take a step back and imagine programming nirvana… what if we could design a better debugger? I’ll be talking about the work me and my team at Raft have been doing to build a new type of debugger and experimental programming experience for building reactive GUI applications, without the mess.

I’m pretty excited to be working on this problem, please share your thoughts and objections and hacker news, would love to hear feedback.

Discuss on Hacker News