Base64 and Base64URL encode the same bytes into output of the same length. They differ in two characters of the 64-character alphabet, plus a convention about trailing padding. That sounds minor until you paste a token into a URL and watch it break.
Two characters that clash with URL syntax
Standard Base64's alphabet is A-Z, a-z, 0-9, plus + and /. The last two are there because 62 alphanumeric characters can't cover 64 values. Both already mean something in a URL. In the form-encoded part of a query string, + is read as a space. / separates path segments.
Put a standard Base64 string into a URL as-is and either character can change what the URL means, or get percent-encoded by whatever handles the URL. Either way the string you get back out is different, and the decode breaks.
Base64URL replaces + with - and / with _. Neither has a reserved meaning in a URL path or query string. The rest of the alphabet and the encoding math stay the same.
Padding
Base64 turns every 3 bytes into 4 characters. When the input length isn't a multiple of 3, standard Base64 adds one or two = characters at the end so the output length stays a multiple of 4. Two bytes of input get one =: ab encodes to YWI=. One byte gets two: a encodes to YQ==. Base64URL usually leaves the padding off, so ab becomes YWI. A decoder can put it back by rounding the length up to the next multiple of 4, and in many formats the length is known some other way anyway.
Some Base64URL implementations still write padding. The format allows both. Leaving it out is more common, because Base64URL exists to be embedded without escaping, and a trailing = causes the same kind of problem in some places: a query string ending in = can be read as a parameter with an empty value.
Why JWTs use Base64URL
A JWT is three Base64URL-encoded segments joined by dots: header.payload.signature. The JWT Decoder and JWT Encoder on this site both treat each segment as Base64URL, not standard Base64. The JWT spec (RFC 7519) requires it. Tokens usually travel in an Authorization header, and sometimes in the URL itself as a query parameter, for flows that can't use headers. A segment with a stray + or / would get mangled the first time it landed in a URL.
A worked example
Take the header {"alg":"HS256","typ":"JWT"}, the standard example from RFC 7519. Its encoding happens to contain neither reserved character, so both formats give the same string: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. This is why people assume the two are interchangeable. For most payloads they are, by luck.
Now encode a payload whose bytes land on the 63rd or 64th alphabet symbol, {"sub":"abc123","role":"admin","note":"a>>b??c"}:
standard Base64: eyJzdWIiOiJhYmMxMjMiLCJyb2xlIjoiYWRtaW4iLCJub3RlIjoiYT4+Yj8/YyJ9
Base64URL: eyJzdWIiOiJhYmMxMjMiLCJyb2xlIjoiYWRtaW4iLCJub3RlIjoiYT4-Yj8_YyJ9
^ ^
+ became -, / became _Put the standard version in a query string unescaped, as in ?payload=eyJ...YT4+Yj8/YyJ9, and most query-string parsers read the + as a space (form-urlencoding treats + and %20 the same). The / can be taken as a path separator by anything that gets the query boundary wrong. Decode the result and you don't get the original JSON back. The Base64URL version has neither character, so a URL parser has nothing to misread.
Padding shows up in RFC 7519's example payload, {"sub":"1234567890","name":"John Doe","iat":1516239022}. In standard Base64 it ends in ==. Its Base64URL form drops those two characters, and the decoder works the padding out from the string's length.
Base32, for a different problem
RFC 4648, the same RFC that defines Base64 and Base64URL, also defines Base32. Its alphabet is 32 characters (A-Z2-7), it's case-insensitive, and it avoids characters that are easy to confuse when read aloud or typed. The cost is size: 5 bytes become 8 characters, about 60% larger, where Base64 adds about 33%.
In exchange it works in harsher places than a URL: case-insensitive filesystems, DNS labels, and values a person copies off a screen or a printout. That's why TOTP secrets (the shared key behind Google Authenticator-style codes, RFC 6238) are normally Base32. People sometimes type them in by hand, and Base64's mixed case plus +// is much easier to get wrong.
The bug this causes
Copy one segment of a JWT, say the payload, into a generic "Base64 decode" tool that assumes the standard alphabet and expects padding. If the segment contains a - or _, a strict standard decoder rejects it, because those characters aren't in its alphabet. If it contains neither, the decoder may still refuse it because the padding is missing.
This is why the same token can decode in one tool and fail in another. Whether it works depends on two things the tool may or may not do: accept - and _, and tolerate a string whose length isn't a multiple of 4. Some JWT segments happen to need neither, which makes the bug look intermittent.
Your code is fine. You need a decoder that implements Base64URL: swap the alphabet back, then restore the padding from the length. In JavaScript, atob() and btoa() only handle standard Base64. Decoding a JWT segment with atob() means converting back to +// and adding the padding first. A JWT-aware tool does that for you.
When standard Base64 is fine
Use it wherever the string won't end up in a URL or a filename. That covers a small image embedded as a data: URI in CSS (it is a URL syntactically, but the browser parses the whole value as a data URI, not as a path or query, so the reserved characters are handled correctly), a Basic Auth header value, or binary data in a JSON field that never goes near a route or query string. Standard Base64 has no downside there and it's the more widely recognized default. Switch to Base64URL when the string has to survive as a URL component or a filename.