diff --git a/api/net/sf/briar/api/protocol/PacketFactory.java b/api/net/sf/briar/api/protocol/PacketFactory.java
index ab9413fc5be60b7c0aaebb17ff7118a0db8e4d58..3f15e8bc8f28a2614152b0ae182aa6226575695e 100644
--- a/api/net/sf/briar/api/protocol/PacketFactory.java
+++ b/api/net/sf/briar/api/protocol/PacketFactory.java
@@ -14,8 +14,8 @@ public interface PacketFactory {
 
 	Request createRequest(BitSet requested, int length);
 
-	SubscriptionUpdate createSubscriptionUpdate(Map<Group, Long> subs,
-			long timestamp);
+	SubscriptionUpdate createSubscriptionUpdate(Map<GroupId, GroupId> holes,
+			Map<Group, Long> subs, long expiry, long timestamp);
 
 	TransportUpdate createTransportUpdate(Collection<Transport> transports,
 			long timestamp);
diff --git a/api/net/sf/briar/api/protocol/ProtocolConstants.java b/api/net/sf/briar/api/protocol/ProtocolConstants.java
index 675feb86a9f7cd153facd5f613629d9a1f537c7e..ba87ec12cfe6d3713c0e4f4e60ce3c0715e1a61f 100644
--- a/api/net/sf/briar/api/protocol/ProtocolConstants.java
+++ b/api/net/sf/briar/api/protocol/ProtocolConstants.java
@@ -21,9 +21,6 @@ public interface ProtocolConstants {
 	/** The maximum length of a property's key or value in UTF-8 bytes. */
 	static final int MAX_PROPERTY_LENGTH = 100;
 
-	/** The maximum number of groups a node may subscribe to. */
-	static final int MAX_GROUPS = 5000;
-
 	/** The maximum length of a group's name in UTF-8 bytes. */
 	static final int MAX_GROUP_NAME_LENGTH = 50;
 
diff --git a/api/net/sf/briar/api/protocol/SubscriptionUpdate.java b/api/net/sf/briar/api/protocol/SubscriptionUpdate.java
index 05a17ed54ad1385071da215ed3fdd30c2e99ba3c..30e0e56397efa1007875f45b40c0ccd3f9f5aa93 100644
--- a/api/net/sf/briar/api/protocol/SubscriptionUpdate.java
+++ b/api/net/sf/briar/api/protocol/SubscriptionUpdate.java
@@ -5,9 +5,18 @@ import java.util.Map;
 /** A packet updating the sender's subscriptions. */
 public interface SubscriptionUpdate {
 
+	/** Returns the holes contained in the update. */
+	Map<GroupId, GroupId> getHoles();
+
 	/** Returns the subscriptions contained in the update. */
 	Map<Group, Long> getSubscriptions();
 
+	/**
+	 * Returns the expiry time of the contact's database. Messages that are
+	 * older than the expiry time must not be sent to the contact.
+	 */
+	long getExpiryTime();
+
 	/**
 	 * Returns the update's timestamp. Updates that are older than the newest
 	 * update received from the same contact must be ignored.
diff --git a/components/net/sf/briar/db/Database.java b/components/net/sf/briar/db/Database.java
index 07fe620d161e9a37e0f169856a954fced9c2a91f..a08da0163a8c139d324cf776d26ed10e8f39ca90 100644
--- a/components/net/sf/briar/db/Database.java
+++ b/components/net/sf/briar/db/Database.java
@@ -88,7 +88,8 @@ interface Database<T> {
 	 * and should be erased by the caller once the transaction has been
 	 * committed or aborted.
 	 * <p>
-	 * Locking: contact write.
+	 * Locking: contact write, subscription write, transport write,
+	 * window write.
 	 */
 	ContactId addContact(T txn, byte[] inSecret, byte[] outSecret,
 			Collection<byte[]> erase) throws DbException;
@@ -224,6 +225,13 @@ interface Database<T> {
 	 */
 	Collection<ContactId> getContacts(T txn) throws DbException;
 
+	/**
+	 * Returns the approximate expiry time of the database.
+	 * <p>
+	 * Locking: message read.
+	 */
+	long getExpiryTime(T txn) throws DbException;
+
 	/**
 	 * Returns the amount of free storage space available to the database, in
 	 * bytes. This is based on the minimum of the space available on the device
@@ -410,22 +418,6 @@ interface Database<T> {
 	 */
 	Collection<Group> getSubscriptions(T txn, ContactId c) throws DbException;
 
-	/**
-	 * Returns the time at which the subscriptions visible to the given contact
-	 * were last modified.
-	 * <p>
-	 * Locking: contact read, subscription read.
-	 */
-	long getSubscriptionsModified(T txn, ContactId c) throws DbException;
-
-	/**
-	 * Returns the time at which a subscription update was last sent to the
-	 * given contact.
-	 * <p>
-	 * Locking: contact read, subscription read.
-	 */
-	long getSubscriptionsSent(T txn, ContactId c) throws DbException;
-
 	/**
 	 * Returns the time at which the local transports were last modified.
 	 * <p>
@@ -456,10 +448,23 @@ interface Database<T> {
 	Collection<ContactId> getVisibility(T txn, GroupId g) throws DbException;
 
 	/**
-	 * Returns the groups to which the user subscribes that are visible to the
-	 * given contact.
+	 * Returns any holes covering unsubscriptions that are visible to the given
+	 * contact, occurred strictly before the given timestamp, and have not yet
+	 * been acknowledged.
+	 * <p>
+	 * Locking: contact read, subscription read.
+	 */
+	Map<GroupId, GroupId> getVisibleHoles(T txn, ContactId c, long timestamp)
+	throws DbException;
+
+	/**
+	 * Returns any subscriptions that are visible to the given contact,
+	 * occurred strictly before the given timestamp, and have not yet been
+	 * acknowledged.
+	 * <p>
+	 * Locking: contact read, subscription read.
 	 */
-	Map<Group, Long> getVisibleSubscriptions(T txn, ContactId c)
+	Map<Group, Long> getVisibleSubscriptions(T txn, ContactId c, long timestamp)
 	throws DbException;
 
 	/**
@@ -615,21 +620,12 @@ interface Database<T> {
 			long timestamp) throws DbException;
 
 	/**
-	 * Records the time at which the subscriptions visible to the given contacts
-	 * were last modified.
-	 * <p>
-	 * Locking: contact read, subscription write.
-	 */
-	void setSubscriptionsModified(T txn, Collection<ContactId> contacts,
-			long timestamp) throws DbException;
-
-	/**
-	 * Records the time at which a subscription update was last sent to the
-	 * given contact.
+	 * Records the time of the latest subscription modification acknowledged by
+	 * the given contact.
 	 * <p>
 	 * Locking: contact read, subscription write.
 	 */
-	void setSubscriptionsSent(T txn, ContactId c, long timestamp)
+	void setSubscriptionsAcked(T txn, ContactId c, long timestamp)
 	throws DbException;
 
 	/**
diff --git a/components/net/sf/briar/db/DatabaseComponentImpl.java b/components/net/sf/briar/db/DatabaseComponentImpl.java
index 271f37fdc4c3e0bf3b258c2df2b6fe21ff6feb40..96f33d4a110a717a3ef9703c064a5010eec159cc 100644
--- a/components/net/sf/briar/db/DatabaseComponentImpl.java
+++ b/components/net/sf/briar/db/DatabaseComponentImpl.java
@@ -173,13 +173,28 @@ DatabaseCleaner.Callback {
 		Collection<byte[]> erase = new ArrayList<byte[]>();
 		contactLock.writeLock().lock();
 		try {
-			T txn = db.startTransaction();
+			subscriptionLock.writeLock().lock();
 			try {
-				c = db.addContact(txn, inSecret, outSecret, erase);
-				db.commitTransaction(txn);
-			} catch(DbException e) {
-				db.abortTransaction(txn);
-				throw e;
+				transportLock.writeLock().lock();
+				try {
+					windowLock.writeLock().lock();
+					try {
+						T txn = db.startTransaction();
+						try {
+							c = db.addContact(txn, inSecret, outSecret, erase);
+							db.commitTransaction(txn);
+						} catch(DbException e) {
+							db.abortTransaction(txn);
+							throw e;
+						}
+					} finally {
+						windowLock.writeLock().unlock();
+					}
+				} finally {
+					transportLock.writeLock().unlock();
+				}
+			} finally {
+				subscriptionLock.writeLock().unlock();
 			}
 		} finally {
 			contactLock.writeLock().unlock();
@@ -606,9 +621,9 @@ DatabaseCleaner.Callback {
 
 	public SubscriptionUpdate generateSubscriptionUpdate(ContactId c)
 	throws DbException {
-		boolean due;
+		Map<GroupId, GroupId> holes;
 		Map<Group, Long> subs;
-		long timestamp;
+		long expiry, timestamp;
 		contactLock.readLock().lock();
 		try {
 			if(!containsContact(c)) throw new NoSuchContactException();
@@ -616,10 +631,10 @@ DatabaseCleaner.Callback {
 			try {
 				T txn = db.startTransaction();
 				try {
-					// Work out whether an update is due
-					long modified = db.getSubscriptionsModified(txn, c);
-					long sent = db.getSubscriptionsSent(txn, c);
-					due = modified >= sent || updateIsDue(sent);
+					timestamp = System.currentTimeMillis() - 1;
+					holes = db.getVisibleHoles(txn, c, timestamp);
+					subs = db.getVisibleSubscriptions(txn, c, timestamp);
+					expiry = db.getExpiryTime(txn);
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -628,26 +643,11 @@ DatabaseCleaner.Callback {
 			} finally {
 				subscriptionLock.readLock().unlock();
 			}
-			if(!due) return null;
-			subscriptionLock.writeLock().lock();
-			try {
-				T txn = db.startTransaction();
-				try {
-					subs = db.getVisibleSubscriptions(txn, c);
-					timestamp = System.currentTimeMillis();
-					db.setSubscriptionsSent(txn, c, timestamp);
-					db.commitTransaction(txn);
-				} catch(DbException e) {
-					db.abortTransaction(txn);
-					throw e;
-				}
-			} finally {
-				subscriptionLock.writeLock().unlock();
-			}
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		return packetFactory.createSubscriptionUpdate(subs, timestamp);
+		return packetFactory.createSubscriptionUpdate(holes, subs, expiry,
+				timestamp);
 	}
 
 	private boolean updateIsDue(long sent) {
@@ -1448,10 +1448,6 @@ DatabaseCleaner.Callback {
 							affected.add(c);
 						}
 					}
-					if(!affected.isEmpty()) {
-						db.setSubscriptionsModified(txn, affected,
-								System.currentTimeMillis());
-					}
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
diff --git a/components/net/sf/briar/db/JdbcDatabase.java b/components/net/sf/briar/db/JdbcDatabase.java
index 916c30ed5c4612b79e2f1579e6ac76d9f3552e03..0a4a074762c622201a1d84d45729b17f4023e2ee 100644
--- a/components/net/sf/briar/db/JdbcDatabase.java
+++ b/components/net/sf/briar/db/JdbcDatabase.java
@@ -90,7 +90,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 	private static final String INDEX_MESSAGES_BY_AUTHOR =
 		"CREATE INDEX messagesByAuthor ON messages (authorId)";
 
-	private static final String INDEX_MESSAGES_BY_BIGINT =
+	private static final String INDEX_MESSAGES_BY_TIMESTAMP =
 		"CREATE INDEX messagesByTimestamp ON messages (timestamp)";
 
 	private static final String INDEX_MESSAGES_BY_SENDABILITY =
@@ -238,12 +238,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 		+ " FOREIGN KEY (contactId) REFERENCES contacts (contactId)"
 		+ " ON DELETE CASCADE)";
 
-	private static final String CREATE_SUBSCRIPTION_TIMESTAMPS =
-		"CREATE TABLE subscriptionTimestamps"
+	private static final String CREATE_SUBSCRIPTION_TIMES =
+		"CREATE TABLE subscriptionTimes"
 		+ " (contactId INT NOT NULL,"
-		+ " sent BIGINT NOT NULL,"
 		+ " received BIGINT NOT NULL,"
-		+ " modified BIGINT NOT NULL,"
+		+ " acked BIGINT NOT NULL,"
+		+ " expiry BIGINT NOT NULL,"
 		+ " PRIMARY KEY (contactId),"
 		+ " FOREIGN KEY (contactId) REFERENCES contacts (contactId)"
 		+ " ON DELETE CASCADE)";
@@ -332,7 +332,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			s.executeUpdate(insertTypeNames(CREATE_MESSAGES));
 			s.executeUpdate(INDEX_MESSAGES_BY_PARENT);
 			s.executeUpdate(INDEX_MESSAGES_BY_AUTHOR);
-			s.executeUpdate(INDEX_MESSAGES_BY_BIGINT);
+			s.executeUpdate(INDEX_MESSAGES_BY_TIMESTAMP);
 			s.executeUpdate(INDEX_MESSAGES_BY_SENDABILITY);
 			s.executeUpdate(insertTypeNames(CREATE_VISIBILITIES));
 			s.executeUpdate(INDEX_VISIBILITIES_BY_GROUP);
@@ -352,7 +352,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			s.executeUpdate(insertTypeNames(CREATE_CONTACT_TRANSPORT_PROPS));
 			s.executeUpdate(insertTypeNames(CREATE_CONNECTION_CONTEXTS));
 			s.executeUpdate(insertTypeNames(CREATE_CONNECTION_WINDOWS));
-			s.executeUpdate(insertTypeNames(CREATE_SUBSCRIPTION_TIMESTAMPS));
+			s.executeUpdate(insertTypeNames(CREATE_SUBSCRIPTION_TIMES));
 			s.executeUpdate(insertTypeNames(CREATE_TRANSPORT_TIMESTAMPS));
 			s.executeUpdate(insertTypeNames(CREATE_FLAGS));
 			s.close();
@@ -521,9 +521,17 @@ abstract class JdbcDatabase implements Database<Connection> {
 			if(rs.next()) throw new DbStateException();
 			rs.close();
 			ps.close();
+			// Create the head-of-list pointer for the visibility list
+			sql = "INSERT INTO visibilities (contactId, deleted)"
+				+ " VALUES (?, ZERO())";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
 			// Initialise the subscription timestamps
-			sql = "INSERT INTO subscriptionTimestamps"
-				+ " (contactId, sent, received, modified)"
+			sql = "INSERT INTO subscriptionTimes"
+				+ " (contactId, received, acked, expiry)"
 				+ " VALUES (?, ZERO(), ZERO(), ZERO())";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
@@ -732,7 +740,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.setBytes(1, g.getId().getBytes());
 			ps.setString(2, g.getName());
 			ps.setBytes(3, g.getPublicKey());
-			ps.setLong(4, System.currentTimeMillis());
+			long now = System.currentTimeMillis();
+			ps.setLong(4, now);
 			int affected = ps.executeUpdate();
 			if(affected != 1) throw new DbStateException();
 			ps.close();
@@ -788,81 +797,57 @@ abstract class JdbcDatabase implements Database<Connection> {
 			// Insert the group ID into the linked list
 			byte[] id = g.getBytes();
 			String sql = "SELECT groupId, nextId, deleted FROM visibilities"
-				+ " WHERE contactId = ?"
-				+ " ORDER BY groupId";
+				+ " WHERE contactId = ? ORDER BY groupId";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
 			rs = ps.executeQuery();
-			if(rs.next()) {
-				// The head pointer of the list exists
-				byte[] groupId = rs.getBytes(1);
-				if(groupId != null) throw new DbStateException();
-				byte[] nextId = rs.getBytes(2);
-				long deleted = rs.getLong(3);
-				// Scan through the list to find the insertion point
-				while(nextId != null && ByteUtils.compare(id, nextId) > 0) {
-					if(!rs.next()) throw new DbStateException();
-					groupId = rs.getBytes(1);
-					if(groupId == null) throw new DbStateException();
-					nextId = rs.getBytes(2);
-					deleted = rs.getLong(3);
-				}
-				rs.close();
-				ps.close();
-				// Update the previous element
-				if(groupId == null) {
-					// Inserting at the head of the list
-					sql = "UPDATE visibilities SET nextId = ?"
-						+ " WHERE contactId = ? AND groupId IS NULL";
-					ps = txn.prepareStatement(sql);
-					ps.setBytes(1, id);
-					ps.setInt(2, c.getInt());
-				} else {
-					// Inserting in the middle or at the tail of the list
-					sql = "UPDATE visibilities SET nextId = ?"
-						+ " WHERE contactId = ? AND groupId = ?";
-					ps = txn.prepareStatement(sql);
-					ps.setBytes(1, id);
-					ps.setInt(2, c.getInt());
-					ps.setBytes(3, groupId);
-				}
-				int affected = ps.executeUpdate();
-				if(affected != 1) throw new DbStateException();
-				ps.close();
-				// Insert the new element
-				sql = "INSERT INTO visibilities"
-					+ " (contactId, groupId, nextId, deleted)"
-					+ " VALUES (?, ?, ?, ?)";
+			if(!rs.next()) throw new DbStateException();
+			// Scan through the list to find the insertion point
+			byte[] groupId = rs.getBytes(1);
+			if(groupId != null) throw new DbStateException();
+			byte[] nextId = rs.getBytes(2);
+			long deleted = rs.getLong(3);
+			while(nextId != null && ByteUtils.compare(id, nextId) > 0) {
+				if(!rs.next()) throw new DbStateException();
+				groupId = rs.getBytes(1);
+				if(groupId == null) throw new DbStateException();
+				nextId = rs.getBytes(2);
+				deleted = rs.getLong(3);
+			}
+			rs.close();
+			ps.close();
+			// Update the previous element
+			if(groupId == null) {
+				// Inserting at the head of the list
+				sql = "UPDATE visibilities SET nextId = ?"
+					+ " WHERE contactId = ? AND groupId IS NULL";
 				ps = txn.prepareStatement(sql);
-				ps.setInt(1, c.getInt());
-				ps.setBytes(2, id);
-				if(nextId == null) ps.setNull(3, Types.BINARY); // At the tail
-				else ps.setBytes(3, nextId); // In the middle
-				ps.setLong(4, deleted);
-				affected = ps.executeUpdate();
-				if(affected != 1) throw new DbStateException();
-				ps.close();
+				ps.setBytes(1, id);
+				ps.setInt(2, c.getInt());
 			} else {
-				// The head pointer of the list does not exist
-				rs.close();
-				ps.close();
-				sql = "INSERT INTO visibilities (contactId, nextId, deleted)"
-					+ " VALUES (?, ?, ZERO())";
-				ps = txn.prepareStatement(sql);
-				ps.setInt(1, c.getInt());
-				ps.setBytes(2, id);
-				int affected = ps.executeUpdate();
-				if(affected != 1) throw new DbStateException();
-				ps.close();
-				sql = "INSERT INTO visibilities (contactId, groupId, deleted)"
-					+ " VALUES (?, ?, ZERO())";
+				// Inserting in the middle or at the tail of the list
+				sql = "UPDATE visibilities SET nextId = ?"
+					+ " WHERE contactId = ? AND groupId = ?";
 				ps = txn.prepareStatement(sql);
-				ps.setInt(1, c.getInt());
-				ps.setBytes(2, id);
-				affected = ps.executeUpdate();
-				if(affected != 1) throw new DbStateException();
-				ps.close();
+				ps.setBytes(1, id);
+				ps.setInt(2, c.getInt());
+				ps.setBytes(3, groupId);
 			}
+			int affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
+			// Insert the new element
+			sql = "INSERT INTO visibilities"
+				+ " (contactId, groupId, nextId, deleted) VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, id);
+			if(nextId == null) ps.setNull(3, Types.BINARY); // At the tail
+			else ps.setBytes(3, nextId); // In the middle
+			ps.setLong(4, deleted);
+			affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -935,19 +920,17 @@ abstract class JdbcDatabase implements Database<Connection> {
 
 	public boolean containsSubscription(Connection txn, GroupId g, long time)
 	throws DbException {
-		boolean found = false;
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT start FROM subscriptions WHERE groupId = ?";
+			String sql = "SELECT NULL FROM subscriptions"
+				+ " WHERE groupId = ? AND start <= ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getBytes());
+			ps.setLong(2, time);
 			rs = ps.executeQuery();
-			if(rs.next()) {
-				long start = rs.getLong(1);
-				if(start <= time) found = true;
-				if(rs.next()) throw new DbStateException();
-			}
+			boolean found = rs.next();
+			if(rs.next()) throw new DbStateException();
 			rs.close();
 			ps.close();
 			return found;
@@ -1128,6 +1111,31 @@ abstract class JdbcDatabase implements Database<Connection> {
 		} else return f.length();
 	}
 
+	public long getExpiryTime(Connection txn) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			long timestamp = 0L;
+			String sql = "SELECT timestamp FROM messages"
+				+ " ORDER BY timestamp LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, 1);
+			rs = ps.executeQuery();
+			if(rs.next()) {
+				timestamp = rs.getLong(1);
+				timestamp -= timestamp % DatabaseConstants.EXPIRY_MODULUS;
+			}
+			if(rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return timestamp;
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public MessageId getGroupMessageParent(Connection txn, MessageId m)
 	throws DbException {
 		PreparedStatement ps = null;
@@ -1371,16 +1379,21 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.close();
 			if(raw != null) return raw;
 			// Do we have a sendable group message with the given ID?
-			sql = "SELECT length, raw FROM messages AS m"
+			sql = "SELECT length, raw FROM messages"
 				+ " JOIN contactSubscriptions AS cs"
-				+ " ON m.groupId = cs.groupId"
-				+ " JOIN visibilities AS v"
-				+ " ON m.groupId = v.groupId AND cs.contactId = v.contactId"
-				+ " JOIN statuses AS s"
-				+ " ON m.messageId = s.messageId AND cs.contactId = s.contactId"
-				+ " WHERE m.messageId = ?"
+				+ " ON messages.groupId = cs.groupId"
+				+ " JOIN visibilities"
+				+ " ON messages.groupId = visibilities.groupId"
+				+ " AND cs.contactId = visibilities.contactId"
+				+ " JOIN statuses"
+				+ " ON messages.messageId = statuses.messageId"
+				+ " AND cs.contactId = statuses.contactId"
+				+ " JOIN subscriptionTimes"
+				+ " ON cs.contactId = subscriptionTimes.contactId"
+				+ " WHERE messages.messageId = ?"
 				+ " AND cs.contactId = ?"
 				+ " AND timestamp >= start"
+				+ " AND timestamp >= expiry"
 				+ " AND status = ?"
 				+ " AND sendability > ZERO()";
 			ps = txn.prepareStatement(sql);
@@ -1561,12 +1574,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT ct.contactId, key, value"
-				+ " FROM contactTransports AS ct"
+			String sql = "SELECT contactTransports.contactId, key, value"
+				+ " FROM contactTransports"
 				+ " LEFT OUTER JOIN contactTransportProperties AS ctp"
-				+ " ON ct.transportId = ctp.transportId"
-				+ " WHERE ct.transportId = ?"
-				+ " ORDER BY ct.contactId";
+				+ " ON contactTransports.transportId = ctp.transportId"
+				+ " WHERE contactTransports.transportId = ?"
+				+ " ORDER BY contactTransports.contactId";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, t.getBytes());
 			rs = ps.executeQuery();
@@ -1639,15 +1652,20 @@ abstract class JdbcDatabase implements Database<Connection> {
 			if(ids.size() == maxMessages)
 				return Collections.unmodifiableList(ids);
 			// Do we have any sendable group messages?
-			sql = "SELECT m.messageId FROM messages AS m"
+			sql = "SELECT messages.messageId FROM messages"
 				+ " JOIN contactSubscriptions AS cs"
 				+ " ON m.groupId = cs.groupId"
-				+ " JOIN visibilities AS v"
-				+ " ON m.groupId = v.groupId AND cs.contactId = v.contactId"
-				+ " JOIN statuses AS s"
-				+ " ON m.messageId = s.messageId AND cs.contactId = s.contactId"
+				+ " JOIN visibilities"
+				+ " ON messages.groupId = visibilities.groupId"
+				+ " AND cs.contactId = visibilities.contactId"
+				+ " JOIN statuses"
+				+ " ON messages.messageId = statuses.messageId"
+				+ " AND cs.contactId = statuses.contactId"
+				+ " JOIN subscriptionTimes"
+				+ " ON cs.contactId = subscriptionTimes.contactId"
 				+ " WHERE cs.contactId = ?"
 				+ " AND timestamp >= start"
+				+ " AND timestamp >= expiry"
 				+ " AND status = ?"
 				+ " AND sendability > ZERO()"
 				+ " ORDER BY timestamp"
@@ -1694,15 +1712,20 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.close();
 			if(total == capacity) return Collections.unmodifiableList(ids);
 			// Do we have any sendable group messages?
-			sql = "SELECT length, m.messageId FROM messages AS m"
+			sql = "SELECT length, messages.messageId FROM messages"
 				+ " JOIN contactSubscriptions AS cs"
-				+ " ON m.groupId = cs.groupId"
-				+ " JOIN visibilities AS v"
-				+ " ON m.groupId = v.groupId AND cs.contactId = v.contactId"
-				+ " JOIN statuses AS s"
-				+ " ON m.messageId = s.messageId AND cs.contactId = s.contactId"
+				+ " ON messages.groupId = cs.groupId"
+				+ " JOIN visibilities"
+				+ " ON messages.groupId = visibilities.groupId"
+				+ " AND cs.contactId = visibilities.contactId"
+				+ " JOIN statuses"
+				+ " ON messages.messageId = statuses.messageId"
+				+ " AND cs.contactId = statuses.contactId"
+				+ " JOIN subscriptionTimes"
+				+ " ON cs.contactId = subscriptionTimes.contactId"
 				+ " WHERE cs.contactId = ?"
 				+ " AND timestamp >= start"
+				+ " AND timestamp >= expiry"
 				+ " AND status = ?"
 				+ " AND sendability > ZERO()"
 				+ " ORDER BY timestamp";
@@ -1801,52 +1824,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public long getSubscriptionsModified(Connection txn, ContactId c)
-	throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT modified FROM subscriptionTimestamps"
-				+ " WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			if(!rs.next()) throw new DbException();
-			long modified = rs.getLong(1);
-			if(rs.next()) throw new DbException();
-			rs.close();
-			ps.close();
-			return modified;
-		} catch(SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public long getSubscriptionsSent(Connection txn, ContactId c)
-	throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT sent FROM subscriptionTimestamps"
-				+ " WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			if(!rs.next()) throw new DbException();
-			long sent = rs.getLong(1);
-			if(rs.next()) throw new DbException();
-			rs.close();
-			ps.close();
-			return sent;
-		} catch(SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public long getTransportsModified(Connection txn) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
@@ -1938,32 +1915,33 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Map<Group, Long> getVisibleSubscriptions(Connection txn, ContactId c)
-	throws DbException {
-		long expiry = getApproximateExpiryTime(txn);
+	public Map<GroupId, GroupId> getVisibleHoles(Connection txn, ContactId c,
+			long timestamp) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql =
-				"SELECT subscriptions.groupId, groupName, groupKey, start"
-				+ " FROM subscriptions JOIN visibilities"
-				+ " ON subscriptions.groupId = visibilities.groupId"
-				+ " WHERE contactId = ?";
+			String sql = "SELECT groupId, nextId FROM visibilities"
+				+ " JOIN subscriptionTimes"
+				+ " ON visibilities.contactId = subscriptionTimes.contactId"
+				+ " WHERE visibilities.contactId = ?"
+				+ " AND deleted > acked AND deleted < ?";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
+			ps.setLong(2, timestamp);
 			rs = ps.executeQuery();
-			Map<Group, Long> subs = new HashMap<Group, Long>();
+			Map<GroupId, GroupId> holes = null;
 			while(rs.next()) {
-				GroupId id = new GroupId(rs.getBytes(1));
-				String name = rs.getString(2);
-				byte[] publicKey = rs.getBytes(3);
-				Group g = groupFactory.createGroup(id, name, publicKey);
-				long start = Math.max(rs.getLong(4), expiry);
-				subs.put(g, start);
+				byte[] b = rs.getBytes(1);
+				GroupId groupId = b == null ? null : new GroupId(b);
+				b = rs.getBytes(2);
+				GroupId nextId = b == null ? null : new GroupId(b);
+				if(holes == null) holes = new HashMap<GroupId, GroupId>();
+				holes.put(groupId, nextId);
 			}
 			rs.close();
 			ps.close();
-			return Collections.unmodifiableMap(subs);
+			if(holes == null) return Collections.emptyMap();
+			return Collections.unmodifiableMap(holes);
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1971,24 +1949,36 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	private long getApproximateExpiryTime(Connection txn) throws DbException {
+	public Map<Group, Long> getVisibleSubscriptions(Connection txn, ContactId c,
+			long timestamp) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			long timestamp = 0L;
-			String sql = "SELECT timestamp FROM messages"
-				+ " ORDER BY timestamp LIMIT ?";
+			String sql =
+				"SELECT subscriptions.groupId, groupName, groupKey, start"
+				+ " FROM subscriptions JOIN visibilities"
+				+ " ON subscriptions.groupId = visibilities.groupId"
+				+ " JOIN subscriptionTimes"
+				+ " ON visibilities.contactId = subscriptionTimes.contactId"
+				+ " WHERE visibilities.contactId = ?"
+				+ " AND start > acked AND start < ?";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, 1);
+			ps.setInt(1, c.getInt());
+			ps.setLong(2, timestamp);
 			rs = ps.executeQuery();
-			if(rs.next()) {
-				timestamp = rs.getLong(1);
-				timestamp -= timestamp % DatabaseConstants.EXPIRY_MODULUS;
+			Map<Group, Long> subs = null;
+			while(rs.next()) {
+				GroupId id = new GroupId(rs.getBytes(1));
+				String name = rs.getString(2);
+				byte[] publicKey = rs.getBytes(3);
+				long start = rs.getLong(4);
+				if(subs == null) subs = new HashMap<Group, Long>();
+				subs.put(groupFactory.createGroup(id, name, publicKey), start);
 			}
-			if(rs.next()) throw new DbStateException();
 			rs.close();
 			ps.close();
-			return timestamp;
+			if(subs == null) return Collections.emptyMap();
+			return Collections.unmodifiableMap(subs);
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -2017,15 +2007,20 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.close();
 			if(found) return true;
 			// Do we have any sendable group messages?
-			sql = "SELECT m.messageId FROM messages AS m"
+			sql = "SELECT messages.messageId FROM messages"
 				+ " JOIN contactSubscriptions AS cs"
-				+ " ON m.groupId = cs.groupId"
-				+ " JOIN visibilities AS v"
-				+ " ON m.groupId = v.groupId AND cs.contactId = v.contactId"
-				+ " JOIN statuses AS s"
-				+ " ON m.messageId = s.messageId AND cs.contactId = s.contactId"
+				+ " ON messages.groupId = cs.groupId"
+				+ " JOIN visibilities"
+				+ " ON messages.groupId = visibilities.groupId"
+				+ " AND cs.contactId = visibilities.contactId"
+				+ " JOIN statuses"
+				+ " ON messages.messageId = statuses.messageId"
+				+ " AND cs.contactId = statuses.contactId"
+				+ " JOIN subscriptionTimes"
+				+ " ON cs.contactId = subscriptionTimes.contactId"
 				+ " WHERE cs.contactId = ?"
 				+ " AND timestamp >= start"
+				+ " AND timestamp >= expiry"
 				+ " AND status = ?"
 				+ " AND sendability > ZERO()"
 				+ " LIMIT ?";
@@ -2600,14 +2595,18 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT NULL FROM messages AS m"
+			String sql = "SELECT NULL FROM messages"
 				+ " JOIN contactSubscriptions AS cs"
-				+ " ON m.groupId = cs.groupId"
-				+ " JOIN visibilities AS v"
-				+ " ON m.groupId = v.groupId AND cs.contactId = v.contactId"
+				+ " ON messages.groupId = cs.groupId"
+				+ " JOIN visibilities"
+				+ " ON messages.groupId = visibilities.groupId"
+				+ " AND cs.contactId = visibilities.contactId"
+				+ " JOIN subscriptionTimes"
+				+ " ON cs.contactId = subscriptionTimes.contactId"
 				+ " WHERE messageId = ?"
 				+ " AND cs.contactId = ?"
-				+ " AND timestamp >= start";
+				+ " AND timestamp >= start"
+				+ " AND timestamp >= expiry";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, m.getBytes());
 			ps.setInt(2, c.getInt());
@@ -2640,7 +2639,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 		ResultSet rs = null;
 		try {
 			// Return if the timestamp isn't fresh
-			String sql = "SELECT received FROM subscriptionTimestamps"
+			String sql = "SELECT received FROM subscriptionTimes"
 				+ " WHERE contactId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
@@ -2679,7 +2678,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			}
 			ps.close();
 			// Update the timestamp
-			sql = "UPDATE subscriptionTimestamps SET received = ?"
+			sql = "UPDATE subscriptionTimes SET received = ?"
 				+ " WHERE contactId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setLong(1, timestamp);
@@ -2694,37 +2693,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void setSubscriptionsModified(Connection txn,
-			Collection<ContactId> contacts, long timestamp) throws DbException {
-		PreparedStatement ps = null;
-		try {
-			String sql = "UPDATE subscriptionTimestamps SET modified = ?"
-				+ " WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setLong(1, timestamp);
-			for(ContactId c : contacts) {
-				ps.setInt(2, c.getInt());
-				ps.addBatch();
-			}
-			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != contacts.size())
-				throw new DbStateException();
-			for(int i = 0; i < batchAffected.length; i++) {
-				if(batchAffected[i] > 1) throw new DbStateException();
-			}
-			ps.close();
-		} catch(SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public void setSubscriptionsSent(Connection txn, ContactId c,
+	public void setSubscriptionsAcked(Connection txn, ContactId c,
 			long timestamp) throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "UPDATE subscriptionTimestamps SET sent = ?"
-				+ " WHERE contactId = ? AND sent < ?";
+			String sql = "UPDATE subscriptionTimes SET acked = ?"
+				+ " WHERE contactId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setLong(1, timestamp);
 			ps.setInt(2, c.getInt());
diff --git a/components/net/sf/briar/protocol/PacketFactoryImpl.java b/components/net/sf/briar/protocol/PacketFactoryImpl.java
index e6239a69fd75420df693d6152bb16c74d5a195ac..75885951d9a69d741473f4eb931ee018c976780a 100644
--- a/components/net/sf/briar/protocol/PacketFactoryImpl.java
+++ b/components/net/sf/briar/protocol/PacketFactoryImpl.java
@@ -9,6 +9,7 @@ import net.sf.briar.api.crypto.MessageDigest;
 import net.sf.briar.api.protocol.Ack;
 import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.MessageId;
 import net.sf.briar.api.protocol.Offer;
 import net.sf.briar.api.protocol.PacketFactory;
@@ -47,9 +48,10 @@ class PacketFactoryImpl implements PacketFactory {
 		return new RequestImpl(requested, length);
 	}
 
-	public SubscriptionUpdate createSubscriptionUpdate(Map<Group, Long> subs,
+	public SubscriptionUpdate createSubscriptionUpdate(
+			Map<GroupId, GroupId> holes, Map<Group, Long> subs, long expiry,
 			long timestamp) {
-		return new SubscriptionUpdateImpl(subs, timestamp);
+		return new SubscriptionUpdateImpl(holes, subs, expiry, timestamp);
 	}
 
 	public TransportUpdate createTransportUpdate(
diff --git a/components/net/sf/briar/protocol/ProtocolWriterImpl.java b/components/net/sf/briar/protocol/ProtocolWriterImpl.java
index 467ef49d98dd2382db9e9e173e71cefc11eaa61b..862ee880bd584fa6e728d8388e9da0781e54d434 100644
--- a/components/net/sf/briar/protocol/ProtocolWriterImpl.java
+++ b/components/net/sf/briar/protocol/ProtocolWriterImpl.java
@@ -10,6 +10,7 @@ import java.util.Map.Entry;
 import net.sf.briar.api.protocol.Ack;
 import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.MessageId;
 import net.sf.briar.api.protocol.Offer;
 import net.sf.briar.api.protocol.ProtocolWriter;
@@ -112,12 +113,23 @@ class ProtocolWriterImpl implements ProtocolWriter {
 	public void writeSubscriptionUpdate(SubscriptionUpdate s)
 	throws IOException {
 		w.writeStructId(Types.SUBSCRIPTION_UPDATE);
+		// Holes
+		w.writeMapStart();
+		for(Entry<GroupId, GroupId> e : s.getHoles().entrySet()) {
+			w.writeBytes(e.getKey().getBytes());
+			w.writeBytes(e.getValue().getBytes());
+		}
+		w.writeMapEnd();
+		// Subscriptions
 		w.writeMapStart();
 		for(Entry<Group, Long> e : s.getSubscriptions().entrySet()) {
 			writeGroup(w, e.getKey());
 			w.writeInt64(e.getValue());
 		}
 		w.writeMapEnd();
+		// Expiry time
+		w.writeInt64(s.getExpiryTime());
+		// Timestamp
 		w.writeInt64(s.getTimestamp());
 		if(flush) out.flush();
 	}
diff --git a/components/net/sf/briar/protocol/SubscriptionUpdateImpl.java b/components/net/sf/briar/protocol/SubscriptionUpdateImpl.java
index 8a1d73ad2d92bea8176ec936a5fc2ac3a7f86821..a98b7fee1f819cf25801483b19594f48bc584a54 100644
--- a/components/net/sf/briar/protocol/SubscriptionUpdateImpl.java
+++ b/components/net/sf/briar/protocol/SubscriptionUpdateImpl.java
@@ -3,22 +3,35 @@ package net.sf.briar.protocol;
 import java.util.Map;
 
 import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.SubscriptionUpdate;
 
 class SubscriptionUpdateImpl implements SubscriptionUpdate {
 
+	private final Map<GroupId, GroupId> holes;
 	private final Map<Group, Long> subs;
-	private final long timestamp;
+	private final long expiry, timestamp;
 
-	SubscriptionUpdateImpl(Map<Group, Long> subs, long timestamp) {
+	SubscriptionUpdateImpl(Map<GroupId, GroupId> holes, Map<Group, Long> subs,
+			long expiry, long timestamp) {
+		this.holes = holes;
 		this.subs = subs;
+		this.expiry = expiry;
 		this.timestamp = timestamp;
 	}
 
+	public Map<GroupId, GroupId> getHoles() {
+		return holes;
+	}
+
 	public Map<Group, Long> getSubscriptions() {
 		return subs;
 	}
 
+	public long getExpiryTime() {
+		return expiry;
+	}
+
 	public long getTimestamp() {
 		return timestamp;
 	}
diff --git a/components/net/sf/briar/protocol/SubscriptionUpdateReader.java b/components/net/sf/briar/protocol/SubscriptionUpdateReader.java
index 83a5e55b662228ad1d6a368b9465f4d1cc207017..de003398b46b92f70d9b130c9b5c42d32623bb01 100644
--- a/components/net/sf/briar/protocol/SubscriptionUpdateReader.java
+++ b/components/net/sf/briar/protocol/SubscriptionUpdateReader.java
@@ -3,17 +3,20 @@ package net.sf.briar.protocol;
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
 
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.Map;
 
 import net.sf.briar.api.FormatException;
 import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.PacketFactory;
 import net.sf.briar.api.protocol.SubscriptionUpdate;
 import net.sf.briar.api.protocol.Types;
+import net.sf.briar.api.protocol.UniqueId;
 import net.sf.briar.api.serial.Consumer;
 import net.sf.briar.api.serial.CountingConsumer;
-import net.sf.briar.api.serial.StructReader;
 import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.StructReader;
 
 class SubscriptionUpdateReader implements StructReader<SubscriptionUpdate> {
 
@@ -32,13 +35,32 @@ class SubscriptionUpdateReader implements StructReader<SubscriptionUpdate> {
 		// Read the data
 		r.addConsumer(counting);
 		r.readStructId(Types.SUBSCRIPTION_UPDATE);
+		// Holes
+		Map<GroupId, GroupId> holes = new HashMap<GroupId, GroupId>();
+		r.setMaxBytesLength(UniqueId.LENGTH);
+		r.readMapStart();
+		while(!r.hasMapEnd()) {
+			byte[] start = r.readBytes();
+			if(start.length != UniqueId.LENGTH) throw new FormatException();
+			byte[] end = r.readBytes();
+			if(end.length != UniqueId.LENGTH)throw new FormatException();
+			holes.put(new GroupId(start), new GroupId(end));
+		}
+		r.readMapEnd();
+		r.resetMaxBytesLength();
+		// Subscriptions
 		r.addStructReader(Types.GROUP, groupReader);
 		Map<Group, Long> subs = r.readMap(Group.class, Long.class);
 		r.removeStructReader(Types.GROUP);
+		// Expiry time
+		long expiry = r.readInt64();
+		if(expiry < 0L) throw new FormatException();
+		// Timestamp
 		long timestamp = r.readInt64();
 		if(timestamp < 0L) throw new FormatException();
 		r.removeConsumer(counting);
 		// Build and return the subscription update
-		return packetFactory.createSubscriptionUpdate(subs, timestamp);
+		return packetFactory.createSubscriptionUpdate(holes, subs, expiry,
+				timestamp);
 	}
 }
diff --git a/test/net/sf/briar/ProtocolIntegrationTest.java b/test/net/sf/briar/ProtocolIntegrationTest.java
index 56a55cf84e786ed41df06c1dd3ffec1f11fc6389..35942d2dfc8a5002680b853dc988126b083cda43 100644
--- a/test/net/sf/briar/ProtocolIntegrationTest.java
+++ b/test/net/sf/briar/ProtocolIntegrationTest.java
@@ -25,6 +25,7 @@ import net.sf.briar.api.protocol.Batch;
 import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Group;
 import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageFactory;
 import net.sf.briar.api.protocol.MessageId;
@@ -172,8 +173,8 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 		Map<Group, Long> subs = new LinkedHashMap<Group, Long>();
 		subs.put(group, 0L);
 		subs.put(group1, 0L);
-		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(subs,
-				timestamp);
+		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(
+				Collections.<GroupId, GroupId>emptyMap(), subs, 0L, timestamp);
 		writer.writeSubscriptionUpdate(s);
 
 		TransportUpdate t = packetFactory.createTransportUpdate(transports,
diff --git a/test/net/sf/briar/db/DatabaseComponentTest.java b/test/net/sf/briar/db/DatabaseComponentTest.java
index f10d3cc74b4815620454c86568f513c8642ce117..3f25f3f5aa24b312e1b4a403113b0a9f0159758e 100644
--- a/test/net/sf/briar/db/DatabaseComponentTest.java
+++ b/test/net/sf/briar/db/DatabaseComponentTest.java
@@ -802,35 +802,6 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.assertIsSatisfied();
 	}
 
-	@Test
-	public void testSubscriptionUpdateNotSentUnlessDue() throws Exception {
-		final long now = System.currentTimeMillis();
-		Mockery context = new Mockery();
-		@SuppressWarnings("unchecked")
-		final Database<Object> database = context.mock(Database.class);
-		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
-		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
-		final PacketFactory packetFactory = context.mock(PacketFactory.class);
-		context.checking(new Expectations() {{
-			allowing(database).startTransaction();
-			will(returnValue(txn));
-			allowing(database).commitTransaction(txn);
-			allowing(database).containsContact(txn, contactId);
-			will(returnValue(true));
-			// Check whether an update is due
-			oneOf(database).getSubscriptionsModified(txn, contactId);
-			will(returnValue(now - 1L));
-			oneOf(database).getSubscriptionsSent(txn, contactId);
-			will(returnValue(now));
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown, packetFactory);
-
-		assertNull(db.generateSubscriptionUpdate(contactId));
-
-		context.assertIsSatisfied();
-	}
-
 	@Test
 	public void testGenerateSubscriptionUpdate() throws Exception {
 		Mockery context = new Mockery();
@@ -847,19 +818,21 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			allowing(database).commitTransaction(txn);
 			allowing(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// Check whether an update is due
-			oneOf(database).getSubscriptionsModified(txn, contactId);
-			will(returnValue(0L));
-			oneOf(database).getSubscriptionsSent(txn, contactId);
-			will(returnValue(0L));
-			// Get the visible subscriptions
-			oneOf(database).getVisibleSubscriptions(txn, contactId);
-			will(returnValue(Collections.singletonMap(group, 0L)));
-			oneOf(database).setSubscriptionsSent(with(txn), with(contactId),
+			// Get the visible holes and subscriptions
+			oneOf(database).getVisibleHoles(with(txn), with(contactId),
 					with(any(long.class)));
+			will(returnValue(Collections.<GroupId, GroupId>emptyMap()));
+			oneOf(database).getVisibleSubscriptions(with(txn), with(contactId),
+					with(any(long.class)));
+			will(returnValue(Collections.singletonMap(group, 0L)));
+			// Get the expiry time
+			oneOf(database).getExpiryTime(txn);
+			will(returnValue(0L));
 			// Create the packet
 			oneOf(packetFactory).createSubscriptionUpdate(
+					with(Collections.<GroupId, GroupId>emptyMap()),
 					with(Collections.singletonMap(group, 0L)),
+					with(any(long.class)),
 					with(any(long.class)));
 			will(returnValue(subscriptionUpdate));
 		}});
@@ -1557,9 +1530,6 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			oneOf(database).getContacts(txn);
 			will(returnValue(both));
 			oneOf(database).removeVisibility(txn, contactId1, groupId);
-			oneOf(database).setSubscriptionsModified(with(txn),
-					with(Collections.singletonList(contactId1)),
-					with(any(long.class)));
 			oneOf(database).commitTransaction(txn);
 			oneOf(listener).eventOccurred(with(any(
 					SubscriptionsUpdatedEvent.class)));
diff --git a/test/net/sf/briar/db/H2DatabaseTest.java b/test/net/sf/briar/db/H2DatabaseTest.java
index 310c3219430994267840a129afce2a12850a7c93..0e6aa4ddb9a9bf8615a3d923cd24acb41fedeb4e 100644
--- a/test/net/sf/briar/db/H2DatabaseTest.java
+++ b/test/net/sf/briar/db/H2DatabaseTest.java
@@ -1628,37 +1628,6 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
-	@Test
-	public void testTimestamps() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact
-		assertEquals(contactId, db.addContact(txn, inSecret, outSecret, erase));
-
-		// The subscription and transport timestamps should be initialised to 0
-		assertEquals(0L, db.getSubscriptionsModified(txn, contactId));
-		assertEquals(0L, db.getSubscriptionsSent(txn, contactId));
-		assertEquals(0L, db.getTransportsModified(txn));
-		assertEquals(0L, db.getTransportsSent(txn, contactId));
-
-		// Update the timestamps
-		db.setSubscriptionsModified(txn,
-				Collections.singletonList(contactId), 1L);
-		db.setSubscriptionsSent(txn, contactId, 2L);
-		db.setTransportsModified(txn, 3L);
-		db.setTransportsSent(txn, contactId, 4L);
-
-		// Check that the updated values were stored
-		assertEquals(1L, db.getSubscriptionsModified(txn, contactId));
-		assertEquals(2L, db.getSubscriptionsSent(txn, contactId));
-		assertEquals(3L, db.getTransportsModified(txn));
-		assertEquals(4L, db.getTransportsSent(txn, contactId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
 	@Test
 	public void testGetMessageBody() throws Exception {
 		Database<Connection> db = open(false);
diff --git a/test/net/sf/briar/protocol/ConstantsTest.java b/test/net/sf/briar/protocol/ConstantsTest.java
index 12f11454712ca57ac051c1b0dd31faef4ac1f9c1..784ecb610c0c5d477725059791fdc96665900bb5 100644
--- a/test/net/sf/briar/protocol/ConstantsTest.java
+++ b/test/net/sf/briar/protocol/ConstantsTest.java
@@ -2,7 +2,6 @@ package net.sf.briar.protocol;
 
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_AUTHOR_NAME_LENGTH;
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_BODY_LENGTH;
-import static net.sf.briar.api.protocol.ProtocolConstants.MAX_GROUPS;
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_GROUP_NAME_LENGTH;
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
 import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PROPERTIES_PER_TRANSPORT;
@@ -16,8 +15,6 @@ import java.security.PrivateKey;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
 
 import net.sf.briar.BriarTestCase;
 import net.sf.briar.TestUtils;
@@ -36,7 +33,6 @@ import net.sf.briar.api.protocol.PacketFactory;
 import net.sf.briar.api.protocol.ProtocolWriter;
 import net.sf.briar.api.protocol.ProtocolWriterFactory;
 import net.sf.briar.api.protocol.RawBatch;
-import net.sf.briar.api.protocol.SubscriptionUpdate;
 import net.sf.briar.api.protocol.Transport;
 import net.sf.briar.api.protocol.TransportId;
 import net.sf.briar.api.protocol.TransportIndex;
@@ -155,30 +151,6 @@ public class ConstantsTest extends BriarTestCase {
 		assertTrue(out.size() <= length);
 	}
 
-	@Test
-	public void testSubscriptionsFitIntoUpdate() throws Exception {
-		// Create the maximum number of maximum-length subscriptions
-		Map<Group, Long> subs = new HashMap<Group, Long>(MAX_GROUPS);
-		byte[] publicKey = new byte[MAX_PUBLIC_KEY_LENGTH];
-		for(int i = 0; i < MAX_GROUPS; i++) {
-			String name = createRandomString(MAX_GROUP_NAME_LENGTH);
-			Group group = groupFactory.createGroup(name, publicKey);
-			subs.put(group, Long.MAX_VALUE);
-		}
-		// Add the subscriptions to an update
-		ByteArrayOutputStream out =
-			new ByteArrayOutputStream(MAX_PACKET_LENGTH);
-		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out,
-				true);
-		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(subs,
-				Long.MAX_VALUE);
-		writer.writeSubscriptionUpdate(s);
-		// Check the size of the serialised update
-		assertTrue(out.size() > MAX_GROUPS *
-				(MAX_GROUP_NAME_LENGTH + MAX_PUBLIC_KEY_LENGTH + 8) + 8);
-		assertTrue(out.size() <= MAX_PACKET_LENGTH);
-	}
-
 	@Test
 	public void testTransportsFitIntoUpdate() throws Exception {
 		// Create the maximum number of plugins, each with the maximum number
diff --git a/test/net/sf/briar/protocol/ProtocolReadWriteTest.java b/test/net/sf/briar/protocol/ProtocolReadWriteTest.java
index 3f2e6a00afa1652c96489a6cb47283a59dd39d53..54434c6b5c1d24bd2a5a221d579abb6032dec702 100644
--- a/test/net/sf/briar/protocol/ProtocolReadWriteTest.java
+++ b/test/net/sf/briar/protocol/ProtocolReadWriteTest.java
@@ -14,6 +14,7 @@ import net.sf.briar.api.protocol.Batch;
 import net.sf.briar.api.protocol.BatchId;
 import net.sf.briar.api.protocol.Group;
 import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
 import net.sf.briar.api.protocol.Message;
 import net.sf.briar.api.protocol.MessageFactory;
 import net.sf.briar.api.protocol.Offer;
@@ -97,7 +98,8 @@ public class ProtocolReadWriteTest extends BriarTestCase {
 		writer.writeRequest(r);
 
 		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(
-				subscriptions, timestamp);
+				Collections.<GroupId, GroupId>emptyMap(), subscriptions, 0L,
+				timestamp);
 		writer.writeSubscriptionUpdate(s);
 
 		TransportUpdate t = packetFactory.createTransportUpdate(transports,
diff --git a/test/net/sf/briar/protocol/simplex/SimplexConnectionReadWriteTest.java b/test/net/sf/briar/protocol/simplex/SimplexConnectionReadWriteTest.java
index 64d43f53f4796d6d72859a41106ec8cf7a10a850..0649fd31038b5dd4739aef90ba7b3386c6562be5 100644
--- a/test/net/sf/briar/protocol/simplex/SimplexConnectionReadWriteTest.java
+++ b/test/net/sf/briar/protocol/simplex/SimplexConnectionReadWriteTest.java
@@ -114,8 +114,8 @@ public class SimplexConnectionReadWriteTest extends BriarTestCase {
 			alice.getInstance(ConnectionWriterFactory.class);
 		ProtocolWriterFactory protoFactory =
 			alice.getInstance(ProtocolWriterFactory.class);
-		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(out,
-				Long.MAX_VALUE, false);
+		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(
+				out, Long.MAX_VALUE, false);
 		OutgoingSimplexConnection simplex = new OutgoingSimplexConnection(db,
 				connRegistry, connFactory, protoFactory, contactId, transportId,
 				transportIndex, transport);