esc_attr and esc_html are not interchangeable

Both take a string and make it safe, which is enough for most people to pick whichever name is nearest. They are not variants of one function — each promises safety in a particular place, and the place is the whole of what distinguishes them. Getting it wrong produces output that looks correct in every test you would think to write.

$title = 'Frames "wide" & <strong>bold</strong>';

echo '<p>' . esc_html( $title ) . '</p>';
echo '<input type="text" value="' . esc_attr( $title ) . '">';
echo '<a href="' . esc_url( $link ) . '">';
echo '<a onclick="show( '' . esc_js( $title ) . '' )">';

// no escaping function makes this safe: the attribute is unquoted,
// so a space ends it and the next word is a new attribute
echo '<div class=' . esc_attr( $class ) . '>';

The awkward part is that esc_html() and esc_attr() are currently implemented almost identically — both run _wp_specialchars() with ENT_QUOTES — so swapping them today usually produces correct output, which is precisely why the habit forms and why it is a liability. The contract is what you are coding against, not the implementation, and the two are free to diverge. The cases where the difference is already real are the other two: esc_url() is the only one that removes a javascript: scheme from an href, and esc_js() is the only one that escapes for the inside of a quoted JavaScript string. Two rules make the choice mechanical. Quote every attribute, because an unquoted one cannot be rescued by any of these. And escape at the point of output rather than on the way into the database — the same stored value may be printed into element text, an attribute and a script on the same page, and only the output site knows which.