6.9 Sync op
Sync ops block the current execution till a result is available. A lot of the ops implemented by Deno are sync ops. Only file system ops come in both variants: sync and async. This is because the file system ops could take time. While in other cases, either sync op doesn't make sense or the op is so simple that implementing them as async would be overkill.
The sync op in our example is
Deno.env.get
. This is a very simple sync op and very useful to learn the principles. Let's go step by step and understand how the ops works end to end. We'll cover both JS and Rust.In the JS space, the hello world v2 program makes a call to get the value of an environment variable named TEST_ENV:
console.log(Deno.env.get('TEST_ENV'));
This immediately calls the JS function getEnv as env.get is mapped to getEnv:
function getEnv(key) {
return core.jsonOpSync("op_get_env", { key })[0];
}
Here are some of the key points from getEnv code:
- Calls core's jsonOpSync to run low-level op
- Low-level op's name is passed to jsonOpSync
- jsonOpSync takes an object as an argument which has a key named key (env name)
- jsonOpSync is a sync function, so it'd block here till a result is available
- The result is always an array and the first element of an array is returned back as that one contains the result of the low-level op
In our example, the following object gets passed to the next function:
{ key: "TEST_ENV" }
jsonOpSync is a heavily used function. This gets called for all the ops that would return a synchronous result or immediate result:
function jsonOpSync(opName, args = {}, ...zeroCopy) {
const argsBuf = encodeJson(args);
const res = dispatch(opName, argsBuf, ...zeroCopy);
return processResponse(decodeJson(res));
}

There are four steps in calling a sync op: encode args, dispatch operation, decode response, and process response. It is a sync op, so there are no further complexities of callbacks or matching requests and responses.
- Encode args
- First, stringify the object
- Then call external ref core.encode to encode them further
- Dispatch
- Call external ref core.send to call op
- Decode response
- First, call external ref core.decode to decode into a string
- Then, parse json
- Process response
- If successful
- return the result
- Else
- return JS error
This is a prerequisite before crossing the bridge. The arguments get encoded before dispatching. There are two steps in the encoding process:
function encodeJson(args) {
const s = JSON.stringify(args);
return core.encode(s);
}
- Stringify all the args that came from the call to the op
- Encode the string
- core.encode is an external reference to v8
- v8 calls it to get encoded args in the form of bytes
We've already seen an overview of the encode function in the previous section.
In our example, encoded args look like this:
[
123, 34, 107, 101, 121, 34,
58, 34, 84, 69, 83, 84,
95, 69, 78, 86, 34, 125
]
Once args are encoded, it's time to call dispatch. Dispatch is a simple one-liner function that calls core's send which is an external reference for v8.
function dispatch(opName, control, ...zeroCopy) {
return send(opsCache[opName], control, ...zeroCopy);
}
The only additional work done by dispatch is to send op id instead of op name.
We've also seen an overview of the send function in chapter 4.
We're going over a sync op, so the result would come synchronously i.e. without any callbacks to the JS space.
function decodeJson(ui8) {
const s = core.decode(ui8);
return JSON.parse(s);
}
decodeJson is exactly the reverse of encodeJson:
- Decode the bytes
- core.decode is an external reference to v8
- v8 calls it to get decode bytes into a string
- JSON parse the string
In our example, the response that came from the send function looks like this:
[
123, 34, 111, 107, 34, 58, 91, 34, 84,
69, 83, 84, 95, 69, 78, 86, 95, 86,
65, 76, 34, 93, 44, 34, 112, 114, 111,
109, 105, 115, 101, 73, 100, 34, 58, 110,
117, 108, 108, 125
]
And the decode response looks like this:
{ ok: [ "TEST_ENV_VAL" ], promiseId: null }
PromiseId is null because this wasn't an async op.
Once args get decoded, process the response:
function processResponse(res) {
if ("ok" in res) {
return res.ok;
}
const ErrorClass = getErrorClass(res.err.className);
if (!ErrorClass) {
throw new Error(
`Unregistered error class: "${res.err.className}"\n ${res.err.message}\n Classes of errors returned from ops should be registered via Deno.core.registerErrorClass().`,
);
}
throw new ErrorClass(res.err.message);
}
The implementation is straightforward:
- If the op resulted in success
- return the result
- Else
- return error
That was all about what happens in JS space. Now, let's see what happens when the op got routed.
The core's send function is on the Rust side i.e. it's part of the Deno code. The send function job is to:
- Route op
- Process response
- Sync
- return result immediately
- Async
- add to pending ops

Here is a relevant code from the send function:
let op = OpTable::route_op(op_id, state.op_state.clone(), bufs);
assert_eq!(state.shared.size(), 0);
match op {
Op::Sync(buf) if !buf.is_empty() => {
rv.set(boxed_slice_to_uint8array(scope, buf).into());
}
Op::Sync(_) => {}
Op::Async(fut) => {
let fut2 = fut.map(move |buf| (op_id, buf));
state.pending_ops.push(fut2.boxed_local());
state.have_unpolled_ops.set(true);
}
// -- OTHER CODE --
}
As can be seen in the code, if the op is sync then v8's rv is set immediately from the op response. However, if the op is async, setting rv is delayed till the op completes and returns a response. Handling of async op response happens in the event loop. We'll see async ops in the next section.
Setting rv is equivalent to crossing the bridge back. The control goes back to v8.
Route op is also quite simple:
- Lookup op_id in op table
- If op_fn is found,
- call op_fn
In our example, Route op makes a call to op_get_env and returns the response.
pub fn route_op(
op_id: OpId,
state: Rc<RefCell<OpState>>,
bufs: BufVec,
) -> Op {
if op_id == 0 {
let ops: HashMap<String, OpId> =
state.borrow().op_table.0.keys().cloned().zip(0..).collect();
let buf = serde_json::to_vec(&ops).map(Into::into).unwrap();
Op::Sync(buf)
} else {
let op_fn = state
.borrow()
.op_table
.0
.get_index(op_id)
.map(|(_, op_fn)| op_fn.clone());
match op_fn {
Some(f) => (f)(state, bufs),
None => Op::NotFound,
}
}
}
}
Finally op_get_env gets called. This function reads the environment variable using a rust function and then return the response:
fn op_get_env(
state: &mut OpState,
args: Value,
_zero_copy: &mut [ZeroCopyBuf],
) -> Result<Value, AnyError> {
let args: GetEnv = serde_json::from_value(args)?;
state.borrow::<Permissions>().check_env()?;
let r = match env::var(args.key) {
Err(env::VarError::NotPresent) => json!([]),
v => json!([v?]),
};
Ok(r)
}
The interesting thing to note that the response is in an array. The reason being that jsonOpSync in the JS space always assumes that an array would be returned and always returns the first element of the array. This is the contract between JS and Rust implementations.
--
That was all about sync ops. It is worth noting that the sync ops are quite easy compared to async ops. Let's see async ops in the next section.