It’s getting all the middleware routers, services, and websites to support both that’s been the challenge because it was a chicken and egg. ISPs didn’t want to do it. Websites wouldn’t do it because there were no customers. Carrier grade NATs bought another decade or two. Manufacturers didn’t bother prioritizing the ipv6 stack because carriers weren’t demanding it so HW had very immature and buggy ipv6 stacks which further prohibited ISPs from turning it on because it was another 1-3 purchase cycles before the stack actually worked correctly. And none of that solves the chicken/egg problem of the lack of eyeball supply / customer demand.
The complexity of IPv6 contributed to some of it. Carrier grade NAT did most of the harm though and that would have been a thing regardless.
Your network servers need two explicit bind() calls for two different protocols, and some logic to select which ones to call, and your main accept() code needs to be able to handle two listening sockets... Theoretically you could create IPv6 socket only and accept both addresses but.. (1) apparently it is disabled on many BSD's by default and (2) even on Linux bind() will fail if you have no IPv6 addresses assigned at all.
Your network client would be better, as there are some libraries which let you connect to ipv4 or ipv6 address, but then IPv6 colon-separated format will trip you. How many clients split on ":" to get port number? Or concatenate (IP, ":", PORT) in the logs / settings? All of those would break.
The really annoying part is that all of these problems were 100% predictable from day 1, and yet someone decided to go ahead with this implementation.