diff --git a/binaries/data/mods/_test.scriptinterface/promises/simple.js b/binaries/data/mods/_test.scriptinterface/promises/simple.js
new file mode 100644
index 0000000000..7f4dcd4a92
--- /dev/null
+++ b/binaries/data/mods/_test.scriptinterface/promises/simple.js
@@ -0,0 +1,30 @@
+let test = 0;
+
+function incrementTest()
+{
+ test += 1;
+}
+
+async function waitAndIncrement(promise)
+{
+ await promise;
+ incrementTest();
+}
+
+{
+ let resolve;
+ const promise = new Promise(res => {
+ incrementTest();
+ resolve = res;
+ });
+ waitAndIncrement(promise);
+ TS_ASSERT_EQUALS(test, 1);
+ resolve();
+ // At this point, waitAndIncrement is still not run, but is now free to run.
+ TS_ASSERT_EQUALS(test, 1);
+}
+
+function endTest()
+{
+ TS_ASSERT_EQUALS(test, 2);
+}
diff --git a/source/scriptinterface/Promises.cpp b/source/scriptinterface/Promises.cpp
new file mode 100644
index 0000000000..a871bc5c39
--- /dev/null
+++ b/source/scriptinterface/Promises.cpp
@@ -0,0 +1,117 @@
+/* Copyright (C) 2024 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#include "precompiled.h"
+
+#include "Promises.h"
+
+#include "lib/debug.h"
+#include "scriptinterface/FunctionWrapper.h"
+#include "scriptinterface/Object.h"
+#include "scriptinterface/ScriptInterface.h"
+#include "scriptinterface/ScriptRequest.h"
+
+namespace Script
+{
+
+void UnhandledRejectedPromise(JSContext* cx, bool, JS::HandleObject promise,
+ JS::PromiseRejectionHandlingState state, void*)
+{
+ if (state == JS::PromiseRejectionHandlingState::Handled)
+ return;
+
+ const ScriptRequest rq{cx};
+ JS::RootedValue reason(cx, JS::GetPromiseResult(promise));
+
+ std::string asString;
+ ScriptFunction::Call(rq, reason, "toString", asString);
+ std::string stack;
+ Script::GetProperty(rq, reason, "stack", stack);
+ LOGERROR("An unhandled promise got rejected:\n%s\n%s", asString, stack);
+}
+
+void JobQueue::runJobs(JSContext*)
+{
+ while (!m_Jobs.empty())
+ {
+ QueueElement& element = m_Jobs.front();
+ ScriptRequest rq{element.scriptInterface};
+ JS::RootedObject localJob{rq.cx, element.job};
+ m_Jobs.pop();
+
+ JS::RootedValue globV{rq.cx, rq.globalValue()};
+ JS::RootedValue rval{rq.cx};
+ JS::Call(rq.cx, globV, localJob, JS::HandleValueArray::empty(), &rval);
+ }
+}
+
+JSObject* JobQueue::getIncumbentGlobal(JSContext* cx)
+{
+ return JS::CurrentGlobalOrNull(cx);
+}
+
+bool JobQueue::enqueuePromiseJob(JSContext* cx, JS::HandleObject, JS::HandleObject job, JS::HandleObject,
+ JS::HandleObject)
+{
+ try
+ {
+ m_Jobs.push({ScriptRequest{cx}.GetScriptInterface(), JS::PersistentRootedObject{cx, job}});
+ return true;
+ }
+ catch (...)
+ {
+ return false;
+ }
+}
+
+bool JobQueue::empty() const
+{
+ return m_Jobs.empty();
+}
+
+js::UniquePtr JobQueue::saveJobQueue(JSContext*)
+{
+ class SavedJobQueue : public JS::JobQueue::SavedJobQueue
+ {
+ public:
+ SavedJobQueue(QueueType& queue) :
+ externQueue{queue},
+ internQueue{std::move(queue)}
+ {}
+
+ ~SavedJobQueue() final
+ {
+ ENSURE(externQueue.empty());
+ externQueue = std::move(internQueue);
+ }
+
+ private:
+ QueueType& externQueue;
+ QueueType internQueue;
+ };
+
+ try
+ {
+ return js::MakeUnique(m_Jobs);
+ }
+ catch (...)
+ {
+ return nullptr;
+ }
+}
+
+} // namespace Script
diff --git a/source/scriptinterface/Promises.h b/source/scriptinterface/Promises.h
new file mode 100644
index 0000000000..6e64da5d54
--- /dev/null
+++ b/source/scriptinterface/Promises.h
@@ -0,0 +1,65 @@
+/* Copyright (C) 2024 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#ifndef INCLUDED_SCRIPTINTERFACE_JOBQUEUE
+#define INCLUDED_SCRIPTINTERFACE_JOBQUEUE
+
+#if MSC_VERSION
+# pragma warning(push, 1)
+#endif
+#include "js/Promise.h"
+#if MSC_VERSION
+# pragma warning(pop)
+#endif
+
+#include
+
+class ScriptInterface;
+
+namespace Script
+{
+void UnhandledRejectedPromise(JSContext* cx, bool, JS::HandleObject promise,
+ JS::PromiseRejectionHandlingState state, void*);
+
+class JobQueue : public JS::JobQueue
+{
+public:
+ ~JobQueue() final = default;
+
+ void runJobs(JSContext*) final;
+
+private:
+ JSObject* getIncumbentGlobal(JSContext* cx) final;
+
+ bool enqueuePromiseJob(JSContext* cx, JS::HandleObject, JS::HandleObject job, JS::HandleObject,
+ JS::HandleObject) final;
+
+ bool empty() const final;
+
+ js::UniquePtr saveJobQueue(JSContext*) final;
+
+ struct QueueElement
+ {
+ const ScriptInterface& scriptInterface;
+ JS::PersistentRootedObject job;
+ };
+ using QueueType = std::queue;
+ QueueType m_Jobs;
+};
+}
+
+#endif // INCLUDED_SCRIPTINTERFACE_JOBQUEUE
diff --git a/source/scriptinterface/tests/test_Promises.h b/source/scriptinterface/tests/test_Promises.h
new file mode 100644
index 0000000000..2b8aa716a9
--- /dev/null
+++ b/source/scriptinterface/tests/test_Promises.h
@@ -0,0 +1,55 @@
+/* Copyright (C) 2024 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+#include "lib/self_test.h"
+
+#include "ps/CLogger.h"
+#include "ps/Filesystem.h"
+#include "scriptinterface/FunctionWrapper.h"
+#include "scriptinterface/JSON.h"
+#include "scriptinterface/Object.h"
+#include "scriptinterface/Promises.h"
+#include "scriptinterface/ScriptInterface.h"
+#include "scriptinterface/ScriptContext.h"
+#include "scriptinterface/StructuredClone.h"
+
+class TestPromises : public CxxTest::TestSuite
+{
+public:
+ void test_simple_promises()
+ {
+ ScriptInterface script("Engine", "Test", g_ScriptContext);
+ ScriptTestSetup(script);
+ TS_ASSERT(script.LoadGlobalScriptFile(L"promises/simple.js"));
+ g_ScriptContext->RunJobs();
+
+ ScriptRequest rq(script);
+ JS::RootedValue global(rq.cx, rq.globalValue());
+ ScriptFunction::CallVoid(rq, global, "endTest");
+ }
+
+ void setUp()
+ {
+ g_VFS = CreateVfs();
+ TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "_test.scriptinterface" / "", VFS_MOUNT_MUST_EXIST));
+ }
+
+ void tearDown()
+ {
+ g_VFS.reset();
+ }
+};