diff --git a/cli/main.rs b/cli/main.rs
index e2ef26cd3a..3d8a80cee2 100644
--- a/cli/main.rs
+++ b/cli/main.rs
@@ -93,6 +93,7 @@ use std::iter::once;
 use std::path::PathBuf;
 use std::pin::Pin;
 use std::rc::Rc;
+use std::sync::atomic::Ordering::Relaxed;
 use std::sync::Arc;
 
 fn create_web_worker_callback(ps: ProcState) -> Arc<CreateWebWorkerCb> {
@@ -1475,4 +1476,7 @@ pub fn main() {
   logger::init(flags.log_level);
 
   unwrap_or_exit(run_basic(get_subcommand(flags)));
+
+  let code = deno_runtime::EXIT_CODE.load(Relaxed);
+  std::process::exit(code);
 }
diff --git a/cli/tests/integration/run_tests.rs b/cli/tests/integration/run_tests.rs
index d6a5a4c252..47041e499b 100644
--- a/cli/tests/integration/run_tests.rs
+++ b/cli/tests/integration/run_tests.rs
@@ -859,6 +859,24 @@ itest!(exit_error42 {
   output: "exit_error42.ts.out",
 });
 
+itest!(set_exit_code_0 {
+  args: "run --no-check --unstable set_exit_code_0.ts",
+  output: "empty.out",
+  exit_code: 0,
+});
+
+itest!(set_exit_code_1 {
+  args: "run --no-check --unstable set_exit_code_1.ts",
+  output: "empty.out",
+  exit_code: 42,
+});
+
+itest!(set_exit_code_2 {
+  args: "run --no-check --unstable set_exit_code_2.ts",
+  output: "empty.out",
+  exit_code: 42,
+});
+
 itest!(heapstats {
   args: "run --quiet --unstable --v8-flags=--expose-gc heapstats.js",
   output: "heapstats.js.out",
diff --git a/cli/tests/testdata/empty.out b/cli/tests/testdata/empty.out
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/tests/testdata/set_exit_code_0.ts b/cli/tests/testdata/set_exit_code_0.ts
new file mode 100644
index 0000000000..7eb14fa3c2
--- /dev/null
+++ b/cli/tests/testdata/set_exit_code_0.ts
@@ -0,0 +1,2 @@
+Deno.core.opSync("op_set_exit_code", 42);
+Deno.exit(0); // Takes precedence.
diff --git a/cli/tests/testdata/set_exit_code_1.ts b/cli/tests/testdata/set_exit_code_1.ts
new file mode 100644
index 0000000000..96ec9889cc
--- /dev/null
+++ b/cli/tests/testdata/set_exit_code_1.ts
@@ -0,0 +1,2 @@
+Deno.core.opSync("op_set_exit_code", 42);
+Deno.exit();
diff --git a/cli/tests/testdata/set_exit_code_2.ts b/cli/tests/testdata/set_exit_code_2.ts
new file mode 100644
index 0000000000..a1f2b5d3c8
--- /dev/null
+++ b/cli/tests/testdata/set_exit_code_2.ts
@@ -0,0 +1,2 @@
+Deno.core.opSync("op_set_exit_code", 42);
+// Exits naturally.
diff --git a/runtime/js/30_os.js b/runtime/js/30_os.js
index 15df6f5549..f6bada6a52 100644
--- a/runtime/js/30_os.js
+++ b/runtime/js/30_os.js
@@ -31,7 +31,14 @@
     exitHandler = fn;
   }
 
-  function exit(code = 0) {
+  function exit(code) {
+    // Set exit code first so unload event listeners can override it.
+    if (typeof code === "number") {
+      core.opSync("op_set_exit_code", code);
+    } else {
+      code = 0;
+    }
+
     // Dispatches `unload` only when it's not dispatched yet.
     if (!window[SymbolFor("isUnloadDispatched")]) {
       // Invokes the `unload` hooks before exiting
@@ -44,7 +51,7 @@
       return;
     }
 
-    core.opSync("op_exit", code);
+    core.opSync("op_exit");
     throw new Error("Code not reachable");
   }
 
diff --git a/runtime/lib.rs b/runtime/lib.rs
index fb6159dd9e..ca250ce20c 100644
--- a/runtime/lib.rs
+++ b/runtime/lib.rs
@@ -1,5 +1,7 @@
 // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
 
+use std::sync::atomic::AtomicI32;
+
 pub use deno_broadcast_channel;
 pub use deno_console;
 pub use deno_crypto;
@@ -29,3 +31,12 @@ pub mod worker;
 
 mod worker_bootstrap;
 pub use worker_bootstrap::BootstrapOptions;
+
+// The global may not be very elegant but:
+//
+// 1. op_exit() calls std::process::exit() so there is not much point storing
+//    the exit code in runtime state
+//
+// 2. storing it in runtime state makes retrieving it again in cli/main.rs
+//    unduly complicated
+pub static EXIT_CODE: AtomicI32 = AtomicI32::new(0);
diff --git a/runtime/ops/os.rs b/runtime/ops/os.rs
index 0a6269ac5a..8e96de75dc 100644
--- a/runtime/ops/os.rs
+++ b/runtime/ops/os.rs
@@ -10,6 +10,7 @@ use deno_core::OpState;
 use serde::Serialize;
 use std::collections::HashMap;
 use std::env;
+use std::sync::atomic::Ordering::Relaxed;
 
 pub fn init() -> Extension {
   Extension::builder()
@@ -23,6 +24,7 @@ pub fn init() -> Extension {
       ("op_hostname", op_sync(op_hostname)),
       ("op_loadavg", op_sync(op_loadavg)),
       ("op_os_release", op_sync(op_os_release)),
+      ("op_set_exit_code", op_sync(op_set_exit_code)),
       ("op_system_memory_info", op_sync(op_system_memory_info)),
     ])
     .build()
@@ -95,7 +97,13 @@ fn op_delete_env(
   Ok(())
 }
 
-fn op_exit(_state: &mut OpState, code: i32, _: ()) -> Result<(), AnyError> {
+fn op_set_exit_code(_: &mut OpState, code: i32, _: ()) -> Result<(), AnyError> {
+  crate::EXIT_CODE.store(code, Relaxed);
+  Ok(())
+}
+
+fn op_exit(_: &mut OpState, _: (), _: ()) -> Result<(), AnyError> {
+  let code = crate::EXIT_CODE.load(Relaxed);
   std::process::exit(code)
 }