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
- sput and stake Return false, Not Throw
- sput Returns a Promise When the Channel Has a Transducer
- Sliding Drops the Oldest, Dropping Drops the Newest
- Always Close Your Channels
- Timeout and alts Pattern
- Transducer Filter Rejection via NOMATCHKEY
- async/await Is Required for Blocking Semantics
- Timeout Errors Have Code 408
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:
putresolves withnulltakeresolves withnullstake()returnsfalse
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:
| Mode | What gets dropped | put returns |
|---|---|---|
dropping | The new value (the one being put) | null |
sliding | The oldest buffered value | true |
// 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
putoperations resolve withfalse - Blocked
takeoperations resolve withnull
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:
- A single timeout can govern an entire loop of operations.
- 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
composechain, if any transducer returnsNOMATCHKEY, all subsequent transducers in the chain are skipped. putreturnsfalseso 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
colibrary and generator functions (co(function *() { yield ... })) were used for blocking semantics. In v3.x,cois no longer bundled. Use nativeasync/awaitinstead, 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.