Turns out this is a known Magento 2 bug with many stores suffering from the same problem.
I initially reported that the problem happens when you click 'Go to checkout' before the basket page had fully loaded. What is actually happening is that concurrent (quick reload) requests on checkout cause cart to empty.
This can be triggered by a double click on the 'Go to checkout' button or multiple refreshes on the checkout page.
Below is a fix that has been tested on Magento 2.2.5. Please note that this is a temporary fix (I wouldn't normally edit core files) until Magento release a full fix.
In vendor/magento/framework/Session/SessionManager.php replace the regenerateId() function with:
public function regenerateId()
{
if (headers_sent()) {
return $this;
}
if ($this->isSessionExists()) {
$oldSessionId = session_id();
session_regenerate_id(); //regen the session
$new_session_id = session_id();
$_SESSION['new_session_id'] = $new_session_id;
// Set destroy timestamp
$_SESSION['destroyed'] = time();
// Write and close current session;
session_commit();
$oldSession = $_SESSION; //called after destroy - see destroy!
// Start session with new session ID
session_id($new_session_id);
ini_set('session.use_strict_mode', 0);
session_start();
ini_set('session.use_strict_mode', 1);
$_SESSION = $oldSession;
// New session does not need them
unset($_SESSION['destroyed']);
unset($_SESSION['new_session_id']);
} else {
session_start();
}
$this->storage->init(isset($_SESSION) ? $_SESSION : []);
if ($this->sessionConfig->getUseCookies()) {
$this->clearSubDomainSessionCookie();
}
return $this;
}
Then in the same file, replace the start() function with:
public function start()
{
if (!$this->isSessionExists()) {
\Magento\Framework\Profiler::start('session_start');
try {
$this->appState->getAreaCode();
} catch (\Magento\Framework\Exception\LocalizedException $e) {
throw new \Magento\Framework\Exception\SessionException(
new \Magento\Framework\Phrase(
'Area code not set: Area code must be set before starting a session.'
),
$e
);
}
// Need to apply the config options so they can be ready by session_start
$this->initIniOptions();
$this->registerSaveHandler();
if (isset($_SESSION['new_session_id'])) {
// Not fully expired yet. Could be lost cookie by unstable network.
session_commit();
session_id($_SESSION['new_session_id']);
}
// potential custom logic for session id (ex. switching between hosts)
$this->setSessionId($this->sidResolver->getSid($this));
session_start();
if (isset($_SESSION['destroyed'])) {
if ($_SESSION['destroyed'] < time()-300) {
$this->destroy(['clear_storage' => true]);
}
}
$this->validator->validate($this);
register_shutdown_function([$this, 'writeClose']);
$this->_addHost();
\Magento\Framework\Profiler::stop('session_start');
}
$this->storage->init(isset($_SESSION) ? $_SESSION : []);
return $this;
}
Fix taken from https://github.com/magento/magento2/issues/12362.