Upload a file by Ajax without submitting the form
This code uploads one file with Ajax, attaching it to an existing node's file field. The content type is file_test, and the field is field_da_files.

(I'm not a professional programmer, so there are no doubt better ways to do this.)

Here's some HTML/JS that talks to a Drupal controller. It sends the node id, and the file to be stored.

In uptest.html.twig:

&lt;p&gt;NID &lt;input type="text" id="nid" name="nid"&gt;&lt;/p&gt;
&lt;input type="file" id="file" name="file" /&gt;
&lt;input type="button" class="button" value="Upload" id="but_upload"&gt;


In library JS file:

(function($, Drupal) {
  Drupal.uptestInitialized = false;
  Drupal.behaviors.uptest = {
    attach: function(context, settings) {
      if (Drupal.uptestInitialized) {
        return;
      }
      Drupal.uptestInitialized = true;
      $(document).ready(function() {
        $("#but_upload").click(function() {
          var fd = new FormData();
          var files = $('#file')[0].files[0];
          if (! files) {
            alert("No files");
            return;
          }
          var nid = $("#nid").val();
          if (! nid) {
            alert("No nid");
            return;
          }
          fd.append('nid', nid);
          fd.append('file', files);

          $.ajax({
            url: '/uptest-save',
            type: 'post',
            data: fd,
            contentType: false,
            processData: false,
            success: function(response){
              let result = response.success;
              if (result){
                alert('yay!');
              }
              else {
                let msg = response.message;
                alert('file not uploaded: ' + msg);
              }
            },
          });
        });
      })
}}})(jQuery, Drupal);


Routing file:

uptest.default_controller_hello:
  path: '/uptest'
  defaults:
    _controller: '\Drupal\uptest\Controller\DefaultController::hello'
    _title: 'uptest'
  requirements:
    _permission: 'access content'

uptest.default_controller_upload:
  path: '/uptest-save'
  defaults:
    _controller: '\Drupal\uptest\Controller\DefaultController::saveFile'
    _title: 'Save'
  requirements:
    _permission: 'access content'


In the controller:

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\File\FileSystemInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use \Drupal\Component\Utility\Bytes;

public function hello() {
  $renderable = [
    '#theme' =&gt; 'uptest',
    '#cache' =&gt; [
      'max-age' =&gt; 0,
    ],
    '#attached' =&gt; [
      'library' =&gt; [
        'uptest/uptest',
      ],
    ],
  ];
  return $renderable;
}

public function saveFile(Request $request) {
  $nid = $request-&gt;get('nid');
  if (!$nid || !is_numeric($nid) || $nid &lt; 0) {
    return $this-&gt;makeUploadErrorResponse('Bad NID format');
  }
  $node = $this-&gt;entityTypeManager-&gt;getStorage('node')
    -&gt;load($nid);
  if (!$node) {
    return $this-&gt;makeUploadErrorResponse('No node with nid of ' . $nid);
  }
  /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile */
  $uploadedFile = $request-&gt;files-&gt;get('file');
  if (!$uploadedFile-&gt;isValid()) {
    return $this-&gt;makeUploadErrorResponse('Invalid file upload.');
  }
  //Get uploaded file metadata.
  $uploadedFileName = $uploadedFile-&gt;getClientOriginalName();
  $uploadedFileExtension = $uploadedFile-&gt;getClientOriginalExtension();
  $uploadedFileSize = $uploadedFile-&gt;getClientSize();
  $uploadedFilePath = $uploadedFile-&gt;getPathname();
  //Get node field metadata.
  $nodeFieldMetadata = $this-&gt;getFileFieldMetaData('file_test', 'field_da_files');
  if (!$nodeFieldMetadata) {
    return $this-&gt;makeUploadErrorResponse('Problem loading file field metadata.');
  }
  //Check uploaded file's extension.
  $allowedExtensions = explode(' ', $nodeFieldMetadata['extensions']);
  if (!in_array($uploadedFileExtension, $allowedExtensions)) {
    return $this-&gt;makeUploadErrorResponse('Extension not allowed.');
  }
  //Check the file size.
  $maxSizeAllowed = Bytes::toInt($nodeFieldMetadata['max file size']);
  if ($uploadedFileSize &gt; $maxSizeAllowed) {
    return $this-&gt;makeUploadErrorResponse('File too large.');
  }
  //Check cardinality.
  /** @var \Drupal\file\Plugin\Field\FieldType\FileFieldItemList $fieldValueInNode */
  $fieldValueInNode = $node-&gt;get('field_da_files');
  $fieldAttachedFileItemList = $fieldValueInNode-&gt;getValue();
  $nodeFileFieldNumAttachments = count($fieldAttachedFileItemList);
  $allowedCardinality = $nodeFieldMetadata['cardinality'];
  if ($nodeFileFieldNumAttachments &gt;= $allowedCardinality) {
    return $this-&gt;makeUploadErrorResponse('Maximum number of files for this node already reached.');
  }
  //OK. Attach the file.
  //Prepare the directory.
  $directory = 'private://' . $nodeFieldMetadata['directory'];
  $result = file_prepare_directory($directory, FileSystemInterface::CREATE_DIRECTORY);
  if (!$result) {
    return $this-&gt;makeUploadErrorResponse('Error preparing directory.');
  }
  //Read the file's contents.
  $fileData = file_get_contents($uploadedFilePath);
  //Save in right dir, creating a file entity instance.
  $savedFile = file_save_data($fileData,
    $directory . '/' . $uploadedFileName,
    FileSystemInterface::EXISTS_RENAME
  );
  if (!$savedFile) {
    return $this-&gt;makeUploadErrorResponse('Error saving file.');
  }
  //Attach to the node.
  $node-&gt;field_da_files[] = [
    'target_id' =&gt; $savedFile-&gt;id(),
  ];
  $node-&gt;save();
  return new JsonResponse([
    'success' =&gt; TRUE,
  ]);
}

/**
 * Make a response for file upload attempt with an error message.
 *
 * @param string $message The error message.
 *
 * @return \Symfony\Component\HttpFoundation\JsonResponse Response to return to client.
 */
protected function makeUploadErrorResponse($message) {
  $result = [
    'success' =&gt; FALSE,
    'message' =&gt; $message,
  ];
  return new JsonResponse($result);
}

/**
 * Get metadata for a file field.
 *
 * @param string $contentType Content type with the field.
 * @param string $fieldName Name of the field.
 *
 * @return array|bool Metadata, or false if a problem.
 */
public function getFileFieldMetaData($contentType, $fieldName) {
  if ($contentType === '' || $fieldName === '') {
    return FALSE;
  }
  $entityFieldManager = \Drupal::service('entity_field.manager');
  $fields = $entityFieldManager-&gt;getFieldDefinitions('node', $contentType);
  if (!$fields || count($fields) === 0) {
    return FALSE;
  }
  //Get file field definition.
  if (!isset($fields[$fieldName])) {
    return FALSE;
  }
  /** @var \Drupal\field\Entity\FieldConfig $fieldDef */
  $fieldDef = $fields[$fieldName];
  //Get settings, doesn't include cardinality.
  $directory = $fieldDef-&gt;getSetting('file_directory');
  //Resolve tokens.
  /** @var \Drupal\Core\Utility\Token $tokenService */
  $tokenService = \Drupal::service('token');
  $directory = $tokenService-&gt;replace($directory);
  $fileExtensions = $fieldDef-&gt;getSetting('file_extensions');
  $maxFileSize = $fieldDef-&gt;getSetting('max_filesize');
  //Get cardinality.
  /** @var \Drupal\field\Entity\FieldStorageConfig $fieldStorageDef */
  $fieldStorageDef = $fieldDef-&gt;getFieldStorageDefinition();
  $cardinality = $fieldStorageDef-&gt;getCardinality();
  //Return results.
  $result = [
    'content type' =&gt; $contentType,
    'field' =&gt; $fieldName,
    'directory' =&gt; $directory,
    'extensions' =&gt; $fileExtensions,
    'max file size' =&gt; $maxFileSize,
    'cardinality' =&gt; $cardinality,
  ];
  return $result;
}


Hope that helps.