Cache controller with json response
The controller from your question runs without exception:
public function demo() {
  $build = [
    '#cache' =&gt; [
      'contexts' =&gt; ['ip'],
    ],
  ];
  return (new CacheableJsonResponse(['test'], 200))-&gt;addCacheableDependency(CacheableMetadata::createFromRenderArray($build));
}

Result:

[&quot;test&quot;]

To produce the exception you need a controller like this:
public function demo() {

  $build = ['#markup' =&gt; 'early rendering'];
  $markup = \Drupal::service('renderer')-&gt;render($build);

  $build = [
    '#cache' =&gt; [
      'contexts' =&gt; ['ip'],
    ],
  ];
  return (new CacheableJsonResponse(['test'], 200))-&gt;addCacheableDependency(CacheableMetadata::createFromRenderArray($build));
}

Result:

LogicException: The controller result claims to be providing relevant
cache metadata, but leaked metadata was detected. Please ensure you
are not rendering content too early.

This may not always be that obvious. Another example is when you try to get an url from an url object, see this answer https://drupal.stackexchange.com/a/187094/47547

Add an URL with cacheable metadata to a response
This is how you avoid the exception, when you render an url for a json response, and at the same time add the correct cache metadata to the response:
  $generated_url = $url-&gt;toString(TRUE);
  $response = new CacheableJsonResponse(['url' =&gt; $generated_url-&gt;getGeneratedUrl()], 200);
  $response-&gt;addCacheableDependency($generated_url);
  return $response;