Gotchas

April 7, 2026 · View on GitHub

Non-obvious behavior, pitfalls, and edge cases in core-async.

Table of Contents

Putting null Closes the Channel

null is a special sentinel value. Putting null onto a channel is equivalent to calling channel.close():

await chan.put(null) // This closes the channel

This means you cannot use null as a data value in channels. If you need to represent "no value," use undefined, an empty object {}, or a custom sentinel.

After a channel is closed:

  • put resolves with null
  • take resolves with null
  • stake() returns false

sput and stake Return false, Not Throw

sput and stake are synchronous, non-blocking alternatives to put and take. When they cannot complete immediately, they return false silently — they do not throw or queue the operation.

const chan = new Channel()

// No taker waiting — value is silently NOT queued
chan.sput('hello') // false

// No value available — returns false, not null
chan.stake() // false

This is fundamentally different from put/take, which block (await) until the operation completes. Use sput/stake only when you intentionally want fire-and-forget semantics.

sput Returns a Promise When the Channel Has a Transducer

When a channel has a transducer, sput must run the value through the transducer (which may be async). In this case, sput returns a Promise<Boolean> instead of a plain Boolean:

import { Channel, map } from 'core-async'

const chan = new Channel(map(x => x + 1))

// This is now a Promise, not a boolean!
const result = chan.sput(5)
result.then(success => console.log(success)) // true or false

Sliding Drops the Oldest, Dropping Drops the Newest

These two modes handle buffer overflow differently, and confusing them is easy:

ModeWhat gets droppedput returns
droppingThe new value (the one being put)null
slidingThe oldest buffered valuetrue
// Dropping: keeps 'hello', drops 'world'
const dropping = new Channel(1, { mode: 'dropping' })
await dropping.put('hello')  // true  (buffered)
await dropping.put('world')  // null  (dropped)
await dropping.take()        // 'hello'

// Sliding: drops 'hello', keeps 'world'
const sliding = new Channel(1, { mode: 'sliding' })
await sliding.put('hello')   // true  (buffered)
await sliding.put('world')   // true  (evicts 'hello')
await sliding.take()         // 'world'

Always Close Your Channels

Channels create pending tasks on the Node.js event loop. If you leave channels open with blocked put or take operations, the event loop won't drain and your process may hang or waste resources.

This is especially important in environments like AWS Lambda, where the runtime may keep the function alive (and billing) while the event loop has pending tasks, even after the callback has been invoked.

;(async () => {
  const chan = new Channel()

  // ... use the channel ...

  chan.close() // Always close when done
})()

Closing a channel immediately resolves all pending operations:

  • Blocked put operations resolve with false
  • Blocked take operations resolve with null

Timeout and alts Pattern

The idiomatic way to implement time-bounded operations is to combine alts with timeout:

import { Channel, alts, timeout } from 'core-async'

const dataChan = new Channel()

;(async () => {
  const t = timeout(3000) // 3-second deadline
  let carryOn = true

  while (carryOn) {
    const [value, chan] = await alts([dataChan, t])

    if (chan === t) {
      carryOn = false
      console.log('Timed out!')
    } else {
      console.log(`Got: ${value}`)
    }
  }
})()

This is more flexible than the timeout option on put/take because:

  1. A single timeout can govern an entire loop of operations.
  2. You can react to the timeout (e.g., log, clean up) instead of catching an error.

Transducer Filter Rejection via NOMATCHKEY

When a filter transducer rejects a value, it internally returns a special NOMATCHKEY string ('no_match_7WmYhpJF33VG3X2dEqCQSwauKRb4zrPIRCh19zDF'). This sentinel propagates through composed transducers and causes put to resolve with false.

You don't need to handle NOMATCHKEY directly — it's an internal mechanism. However, be aware that:

  • In a compose chain, if any transducer returns NOMATCHKEY, all subsequent transducers in the chain are skipped.
  • put returns false so you can detect that the value was rejected.
const chan = new Channel(filter(x => x > 10))

;(async () => {
  const accepted = await chan.put(5)
  if (!accepted) {
    console.log('Value was filtered out')
  }
})()

async/await Is Required for Blocking Semantics

put and take return Promises. To get blocking (sequential) behavior, you must use await inside an async function:

// CORRECT: blocking behavior with async/await
;(async () => {
  await chan.put(1)           // Blocks until taken
  const v = await chan.take() // Blocks until a value is available
})()

// WRONG: these return promises but don't block
chan.put(1)    // Returns a promise, does not wait
chan.take()    // Returns a promise, does not wait

Without await, you can still use .then() chains, but you lose the sequential readability that makes CSP patterns ergonomic.

Note: In v2.x, the co library and generator functions (co(function *() { yield ... })) were used for blocking semantics. In v3.x, co is no longer bundled. Use native async/await instead, which provides the same sequential behavior without any external dependency.

Timeout Errors Have Code 408

When put or take times out (via the timeout option), the thrown error has a code property set to 408 (HTTP "Request Timeout" status code):

try {
  await chan.take({ timeout: 1000 })
} catch (err) {
  console.log(err.code)    // 408
  console.log(err.message) // "'take' timed out after 1000 ms. No data was taken off the channel."
}

This convention allows you to distinguish timeout errors from other errors programmatically.