diff --git a/src/util/IntrusiveHashSet.hxx b/src/util/IntrusiveHashSet.hxx
new file mode 100644
index 000000000..8ddeefbfd
--- /dev/null
+++ b/src/util/IntrusiveHashSet.hxx
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2022 Max Kellermann <max.kellermann@gmail.com>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the
+ * distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE
+ * FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#pragma once
+
+#include "IntrusiveList.hxx"
+
+#include <algorithm> // for std::all_of()
+#include <array>
+#include <numeric> // for std::accumulate()
+
+template<IntrusiveHookMode mode=IntrusiveHookMode::NORMAL>
+struct IntrusiveHashSetHook {
+	using SiblingsHook = IntrusiveListHook<mode>;
+
+	SiblingsHook intrusive_hash_set_siblings;
+};
+
+/**
+ * Detect the hook type.
+ */
+template<typename U>
+struct IntrusiveHashSetHookDetection {
+	/* TODO can this be simplified somehow, without checking for
+	   all possible enum values? */
+	using type = std::conditional_t<std::is_base_of_v<IntrusiveHashSetHook<IntrusiveHookMode::NORMAL>, U>,
+					IntrusiveHashSetHook<IntrusiveHookMode::NORMAL>,
+					std::conditional_t<std::is_base_of_v<IntrusiveHashSetHook<IntrusiveHookMode::TRACK>, U>,
+							   IntrusiveHashSetHook<IntrusiveHookMode::TRACK>,
+							   std::conditional_t<std::is_base_of_v<IntrusiveHashSetHook<IntrusiveHookMode::AUTO_UNLINK>, U>,
+									      IntrusiveHashSetHook<IntrusiveHookMode::AUTO_UNLINK>,
+									      void>>>;
+};
+
+/**
+ * For classes which embed #IntrusiveHashSetHook as base class.
+ */
+template<typename T>
+struct IntrusiveHashSetBaseHookTraits {
+	template<typename U>
+	using Hook = typename IntrusiveHashSetHookDetection<U>::type;
+
+	using ListHookTraits =
+		IntrusiveListMemberHookTraits<&T::intrusive_hash_set_siblings>;
+
+	static constexpr T *Cast(Hook<T> *node) noexcept {
+		return static_cast<T *>(node);
+	}
+
+	static constexpr auto &ToHook(T &t) noexcept {
+		return static_cast<Hook<T> &>(t);
+	}
+};
+
+/**
+ * For classes which embed #IntrusiveListHook as member.
+ */
+template<auto member>
+struct IntrusiveHashSetMemberHookTraits {
+	using T = MemberPointerContainerType<decltype(member)>;
+	using _Hook = MemberPointerType<decltype(member)>;
+
+	template<typename Dummy>
+	using Hook = _Hook;
+};
+
+/**
+ * A hash table implementation which stores pointers to items which
+ * have an embedded #IntrusiveHashSetHook.  The actual table is
+ * embedded with a compile-time fixed size in this object.
+ */
+template<typename T, std::size_t table_size,
+	 typename Hash=T::Hash, typename Equal=T::Equal,
+	 typename HookTraits=IntrusiveHashSetBaseHookTraits<T>,
+	 bool constant_time_size=false>
+class IntrusiveHashSet {
+	[[no_unique_address]]
+	OptionalCounter<constant_time_size> counter;
+
+	[[no_unique_address]]
+	Hash hash;
+
+	[[no_unique_address]]
+	Equal equal;
+
+	struct SlotHookTraits {
+		template<typename U>
+		using HashSetHook = typename HookTraits::template Hook<U>;
+
+		template<typename U>
+		using ListHook = IntrusiveListMemberHookTraits<&HashSetHook<U>::intrusive_hash_set_siblings>;
+
+		template<typename U>
+		using Hook = typename HashSetHook<U>::SiblingsHook;
+
+		static constexpr T *Cast(IntrusiveListNode *node) noexcept {
+			auto *hook = ListHook<T>::Cast(node);
+			return HookTraits::Cast(hook);
+		}
+
+		static constexpr auto &ToHook(T &t) noexcept {
+			auto &hook = HookTraits::ToHook(t);
+			return hook.intrusive_hash_set_siblings;
+		}
+	};
+
+	using Slot = IntrusiveList<T, SlotHookTraits>;
+	std::array<Slot, table_size> table;
+
+	using slot_iterator = typename Slot::iterator;
+
+public:
+	using value_type = T;
+	using reference = T &;
+	using const_reference = const T &;
+	using pointer = T *;
+	using const_pointer = const T *;
+	using size_type = std::size_t;
+
+	[[nodiscard]]
+	IntrusiveHashSet() noexcept = default;
+
+	[[nodiscard]]
+	constexpr bool empty() noexcept {
+		if constexpr (constant_time_size)
+			return size() == 0;
+		else
+			return std::all_of(table.begin(), table.end(), [](const auto &slot){
+				return slot.empty();
+			});
+	}
+
+	[[nodiscard]]
+	constexpr size_type size() noexcept {
+		if constexpr (constant_time_size)
+			return counter;
+		else
+			return std::accumulate(table.begin(), table.end(), size_type{}, [](std::size_t n, const auto &slot){
+				return n + slot.size();
+			});
+	}
+
+	constexpr void clear() noexcept {
+		for (auto &i : table)
+			i.clear();
+
+		counter.reset();
+	}
+
+	template<typename D>
+	constexpr void clear_and_dispose(D &&disposer) noexcept {
+		for (auto &i : table)
+			i.clear_and_dispose(disposer);
+
+		counter.reset();
+	}
+
+	[[nodiscard]]
+	static constexpr slot_iterator iterator_to(reference item) noexcept {
+		return Slot::iterator_to(item);
+	}
+
+	[[nodiscard]] [[gnu::pure]]
+	constexpr std::pair<slot_iterator, bool> insert_check(const auto &key) noexcept {
+		auto &slot = GetSlot(key);
+		for (auto &i : slot)
+			if (equal(key, i))
+				return {slot.iterator_to(i), false};
+
+		return {slot.begin(), true};
+	}
+
+	constexpr void insert(slot_iterator slot, reference item) noexcept {
+		++counter;
+		GetSlot(item).insert(slot, item);
+	}
+
+	constexpr void insert(reference item) noexcept {
+		++counter;
+		GetSlot(item).push_front(item);
+	}
+
+	constexpr slot_iterator erase(slot_iterator i) noexcept {
+		--counter;
+		return GetSlot(*i).erase(i);
+	}
+
+	[[nodiscard]] [[gnu::pure]]
+	constexpr slot_iterator find(const auto &key) noexcept {
+		auto &slot = GetSlot(key);
+		for (auto &i : slot)
+			if (equal(key, i))
+				return slot.iterator_to(i);
+
+		return end();
+	}
+
+	constexpr slot_iterator end() noexcept {
+		return table.front().end();
+	}
+
+private:
+	template<typename K>
+	[[gnu::pure]]
+	[[nodiscard]]
+	constexpr auto &GetSlot(K &&key) noexcept {
+		const auto h = hash(std::forward<K>(key));
+		return table[h % table_size];
+	}
+};
diff --git a/test/util/TestIntrusiveHashSet.cxx b/test/util/TestIntrusiveHashSet.cxx
new file mode 100644
index 000000000..4e2c6770e
--- /dev/null
+++ b/test/util/TestIntrusiveHashSet.cxx
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2022 Max Kellermann <max.kellermann@gmail.com>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the
+ * distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE
+ * FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "util/IntrusiveHashSet.hxx"
+
+#include <gtest/gtest.h>
+
+#include <string>
+
+namespace {
+
+struct IntItem final : IntrusiveHashSetHook<IntrusiveHookMode::TRACK> {
+	int value;
+
+	IntItem(int _value) noexcept:value(_value) {}
+
+	struct Hash {
+		constexpr std::size_t operator()(const IntItem &i) noexcept {
+			return i.value;
+		}
+
+		constexpr std::size_t operator()(int i) noexcept {
+			return i;
+		}
+	};
+
+	struct Equal {
+		constexpr bool operator()(const IntItem &a,
+					  const IntItem &b) noexcept {
+			return a.value == b.value;
+		}
+	};
+};
+
+} // anonymous namespace
+
+TEST(IntrusiveHashSet, Basic)
+{
+	IntItem a{1}, b{2}, c{3}, d{4}, e{5}, f{1};
+
+	IntrusiveHashSet<IntItem, 3> set;
+
+	{
+		auto [position, inserted] = set.insert_check(2);
+		ASSERT_TRUE(inserted);
+		set.insert(position, b);
+	}
+
+	ASSERT_FALSE(set.insert_check(2).second);
+	ASSERT_FALSE(set.insert_check(b).second);
+
+	{
+		auto [position, inserted] = set.insert_check(a);
+		ASSERT_TRUE(inserted);
+		set.insert(position, a);
+	}
+
+	set.insert(c);
+
+	ASSERT_EQ(set.size(), 3);
+
+	ASSERT_NE(set.find(c), set.end());
+	ASSERT_EQ(set.find(c), set.iterator_to(c));
+	ASSERT_NE(set.find(3), set.end());
+	ASSERT_EQ(set.find(3), set.iterator_to(c));
+
+	ASSERT_EQ(set.find(4), set.end());
+	ASSERT_EQ(set.find(d), set.end());
+
+	set.erase(set.iterator_to(c));
+
+	ASSERT_EQ(set.size(), 2);
+	ASSERT_EQ(set.find(3), set.end());
+	ASSERT_EQ(set.find(c), set.end());
+
+	set.insert(c);
+	set.insert(d);
+	set.insert(e);
+
+	ASSERT_EQ(set.size(), 5);
+	ASSERT_FALSE(set.insert_check(1).second);
+	ASSERT_EQ(set.insert_check(1).first, set.iterator_to(a));
+	ASSERT_FALSE(set.insert_check(f).second);
+	ASSERT_EQ(set.insert_check(f).first, set.iterator_to(a));
+
+	ASSERT_EQ(set.find(1), set.iterator_to(a));
+	ASSERT_EQ(set.find(2), set.iterator_to(b));
+	ASSERT_EQ(set.find(3), set.iterator_to(c));
+	ASSERT_EQ(set.find(4), set.iterator_to(d));
+	ASSERT_EQ(set.find(5), set.iterator_to(e));
+
+	ASSERT_EQ(set.find(a), set.iterator_to(a));
+	ASSERT_EQ(set.find(b), set.iterator_to(b));
+	ASSERT_EQ(set.find(c), set.iterator_to(c));
+	ASSERT_EQ(set.find(d), set.iterator_to(d));
+	ASSERT_EQ(set.find(e), set.iterator_to(e));
+
+	set.erase(set.find(1));
+
+	{
+		auto [position, inserted] = set.insert_check(f);
+		ASSERT_TRUE(inserted);
+		set.insert(position, f);
+	}
+
+	ASSERT_EQ(set.find(a), set.iterator_to(f));
+	ASSERT_EQ(set.find(f), set.iterator_to(f));
+	ASSERT_EQ(set.find(1), set.iterator_to(f));
+
+	set.clear_and_dispose([](auto *i){ i->value = -1; });
+
+	ASSERT_EQ(a.value, 1);
+	ASSERT_EQ(b.value, -1);
+	ASSERT_EQ(c.value, -1);
+	ASSERT_EQ(d.value, -1);
+	ASSERT_EQ(e.value, -1);
+	ASSERT_EQ(f.value, -1);
+}
diff --git a/test/util/meson.build b/test/util/meson.build
index d69c2f3b3..2163af6ac 100644
--- a/test/util/meson.build
+++ b/test/util/meson.build
@@ -6,6 +6,7 @@ test(
     'TestDivideString.cxx',
     'TestException.cxx',
     'TestIntrusiveForwardList.cxx',
+    'TestIntrusiveHashSet.cxx',
     'TestIntrusiveList.cxx',
     'TestMimeType.cxx',
     'TestSplitString.cxx',