diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigotWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigotWorldManager.java
index c6443bd05..8a92526f1 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigotWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigotWorldManager.java
@@ -26,12 +26,14 @@
 package org.geysermc.platform.spigot.world;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
 import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
 import org.bukkit.Bukkit;
 import org.bukkit.World;
 import org.bukkit.block.Biome;
 import org.bukkit.block.Block;
+import org.bukkit.entity.Player;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.GeyserWorldManager;
@@ -93,23 +95,32 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
 
     @Override
     public int getBlockAt(GeyserSession session, int x, int y, int z) {
-        if (session.getPlayerEntity() == null) {
-            return BlockTranslator.AIR;
-        }
-        if (Bukkit.getPlayer(session.getPlayerEntity().getUsername()) == null) {
+        Player bukkitPlayer;
+        if ((this.isLegacy && !this.isViaVersion)
+            || session.getPlayerEntity() == null
+            || (bukkitPlayer = Bukkit.getPlayer(session.getPlayerEntity().getUsername())) == null) {
             return BlockTranslator.AIR;
         }
+        World world = bukkitPlayer.getWorld();
         if (isLegacy) {
-            return getLegacyBlock(session, x, y, z, isViaVersion);
+            return getLegacyBlock(session, x, y, z, true);
         }
         //TODO possibly: detect server version for all versions and use ViaVersion for block state mappings like below
-        return BlockTranslator.getJavaIdBlockMap().getOrDefault(Bukkit.getPlayer(session.getPlayerEntity().getUsername()).getWorld().getBlockAt(x, y, z).getBlockData().getAsString(), 0);
+        return BlockTranslator.getJavaIdBlockMap().getOrDefault(world.getBlockAt(x, y, z).getBlockData().getAsString(), 0);
+    }
+
+    public static int getLegacyBlock(GeyserSession session, int x, int y, int z, boolean isViaVersion) {
+        if (isViaVersion) {
+            return getLegacyBlock(Bukkit.getPlayer(session.getPlayerEntity().getUsername()).getWorld(), x, y, z, true);
+        } else {
+            return BlockTranslator.AIR;
+        }
     }
 
     @SuppressWarnings("deprecation")
-    public static int getLegacyBlock(GeyserSession session, int x, int y, int z, boolean isViaVersion) {
+    public static int getLegacyBlock(World world, int x, int y, int z, boolean isViaVersion) {
         if (isViaVersion) {
-            Block block = Bukkit.getPlayer(session.getPlayerEntity().getUsername()).getWorld().getBlockAt(x, y, z);
+            Block block = world.getBlockAt(x, y, z);
             // Black magic that gets the old block state ID
             int oldBlockId = (block.getType().getId() << 4) | (block.getData() & 0xF);
             // Convert block state from old version -> 1.13 -> 1.13.1 -> 1.14 -> 1.15 -> 1.16 -> 1.16.2
@@ -124,6 +135,42 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
         }
     }
 
+    @Override
+    public void getBlocksInSection(GeyserSession session, int x, int y, int z, Chunk chunk) {
+        Player bukkitPlayer;
+        if ((this.isLegacy && !this.isViaVersion)
+            || session.getPlayerEntity() == null
+            || (bukkitPlayer = Bukkit.getPlayer(session.getPlayerEntity().getUsername())) == null) {
+            return;
+        }
+        World world = bukkitPlayer.getWorld();
+        if (this.isLegacy)  {
+            for (int blockY = 0; blockY < 16; blockY++) { // Cache-friendly iteration order
+                for (int blockZ = 0; blockZ < 16; blockZ++) {
+                    for (int blockX = 0; blockX < 16; blockX++) {
+                        chunk.set(blockX, blockY, blockZ, getLegacyBlock(world, (x << 4) + blockX, (y << 4) + blockY, (z << 4) + blockZ, true));
+                    }
+                }
+            }
+        } else {
+            //TODO: see above TODO in getBlockAt
+            for (int blockY = 0; blockY < 16; blockY++) { // Cache-friendly iteration order
+                for (int blockZ = 0; blockZ < 16; blockZ++) {
+                    for (int blockX = 0; blockX < 16; blockX++) {
+                        Block block = world.getBlockAt((x << 4) + blockX, (y << 4) + blockY, (z << 4) + blockZ);
+                        int id = BlockTranslator.getJavaIdBlockMap().getOrDefault(block.getBlockData().getAsString(), 0);
+                        chunk.set(blockX, blockY, blockZ, id);
+                    }
+                }
+            }
+        }
+    }
+
+    @Override
+    public boolean hasMoreBlockDataThanChunkCache() {
+        return true;
+    }
+
     @Override
     @SuppressWarnings("deprecation")
     public int[] getBiomeDataAt(GeyserSession session, int x, int z) {
diff --git a/connector/pom.xml b/connector/pom.xml
index 741acee51..5df525567 100644
--- a/connector/pom.xml
+++ b/connector/pom.xml
@@ -111,7 +111,7 @@
         <dependency>
             <groupId>com.github.steveice10</groupId>
             <artifactId>mcprotocollib</artifactId>
-            <version>976c2d0f89</version>
+            <version>3a69a0614c</version>
             <scope>compile</scope>
             <exclusions>
                 <exclusion>
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/cache/ChunkCache.java b/connector/src/main/java/org/geysermc/connector/network/session/cache/ChunkCache.java
index 14825b71a..7bf84b8db 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/cache/ChunkCache.java
+++ b/connector/src/main/java/org/geysermc/connector/network/session/cache/ChunkCache.java
@@ -32,7 +32,7 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
 import org.geysermc.connector.bootstrap.GeyserBootstrap;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
-import org.geysermc.connector.network.translators.world.chunk.ChunkPosition;
+import org.geysermc.connector.utils.MathUtils;
 
 public class ChunkCache {
 
@@ -48,27 +48,31 @@ public class ChunkCache {
         }
     }
 
-    public void addToCache(Column chunk) {
+    public Column addToCache(Column chunk) {
         if (!cache) {
-            return;
+            return chunk;
         }
 
-        long chunkPosition = ChunkPosition.toLong(chunk.getX(), chunk.getZ());
+        long chunkPosition = MathUtils.chunkPositionToLong(chunk.getX(), chunk.getZ());
         Column existingChunk;
-        if (chunk.getBiomeData() != null // Only consider merging columns if the new chunk isn't a full chunk
+        if (chunk.getBiomeData() == null // Only consider merging columns if the new chunk isn't a full chunk
             && (existingChunk = chunks.getOrDefault(chunkPosition, null)) != null) { // Column is already present in cache, we can merge with existing
+            boolean changed = false;
             for (int i = 0; i < chunk.getChunks().length; i++) { // The chunks member is final, so chunk.getChunks() will probably be inlined and then completely optimized away
                 if (chunk.getChunks()[i] != null) {
                     existingChunk.getChunks()[i] = chunk.getChunks()[i];
+                    changed = true;
                 }
             }
+            return changed ? existingChunk : null;
         } else {
             chunks.put(chunkPosition, chunk);
+            return chunk;
         }
     }
 
     public Column getChunk(int chunkX, int chunkZ)  {
-        long chunkPosition = ChunkPosition.toLong(chunkX, chunkZ);
+        long chunkPosition = MathUtils.chunkPositionToLong(chunkX, chunkZ);
         return chunks.getOrDefault(chunkPosition, null);
     }
 
@@ -111,7 +115,7 @@ public class ChunkCache {
             return;
         }
 
-        long chunkPosition = ChunkPosition.toLong(chunkX, chunkZ);
+        long chunkPosition = MathUtils.chunkPositionToLong(chunkX, chunkZ);
         chunks.remove(chunkPosition);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaChunkDataTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaChunkDataTranslator.java
index ddd5e004d..cd1a321c2 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaChunkDataTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaChunkDataTranslator.java
@@ -25,6 +25,7 @@
 
 package org.geysermc.connector.network.translators.java.world;
 
+import com.github.steveice10.mc.protocol.data.game.chunk.Column;
 import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerChunkDataPacket;
 import com.nukkitx.nbt.NBTOutputStream;
 import com.nukkitx.nbt.NbtMap;
@@ -32,8 +33,8 @@ import com.nukkitx.nbt.NbtUtils;
 import com.nukkitx.network.VarInts;
 import com.nukkitx.protocol.bedrock.packet.LevelChunkPacket;
 import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
 import io.netty.buffer.ByteBufOutputStream;
-import io.netty.buffer.Unpooled;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.BiomeTranslator;
@@ -66,57 +67,69 @@ public class JavaChunkDataTranslator extends PacketTranslator<ServerChunkDataPac
             return;
         }
 
+        // Merge received column with cache on network thread
+        Column mergedColumn = session.getChunkCache().addToCache(packet.getColumn());
+        if (mergedColumn == null) { // There were no changes?!?
+            return;
+        }
+
+        boolean isNonFullChunk = packet.getColumn().getBiomeData() == null;
+
         GeyserConnector.getInstance().getGeneralThreadPool().execute(() -> {
             try {
-                // Non-full chunks don't have all the chunk data, and Bedrock won't accept that
-                final boolean isNonFullChunk = (packet.getColumn().getBiomeData() == null);
-
-                ChunkUtils.ChunkData chunkData = ChunkUtils.translateToBedrock(session, packet.getColumn(), isNonFullChunk);
-                ByteBuf byteBuf = Unpooled.buffer(32);
-                ChunkSection[] sections = chunkData.sections;
+                ChunkUtils.ChunkData chunkData = ChunkUtils.translateToBedrock(session, mergedColumn, isNonFullChunk);
+                ChunkSection[] sections = chunkData.getSections();
 
+                // Find highest section
                 int sectionCount = sections.length - 1;
-                while (sectionCount >= 0 && sections[sectionCount].isEmpty()) {
+                while (sectionCount >= 0 && sections[sectionCount] == null) {
                     sectionCount--;
                 }
                 sectionCount++;
 
+                // Estimate chunk size
+                int size = 0;
                 for (int i = 0; i < sectionCount; i++) {
-                    ChunkSection section = chunkData.sections[i];
-                    section.writeToNetwork(byteBuf);
+                    ChunkSection section = sections[i];
+                    size += (section != null ? section : ChunkUtils.EMPTY_SECTION).estimateNetworkSize();
                 }
+                size += 256; // Biomes
+                size += 1; // Border blocks
+                size += 1; // Extra data length (always 0)
+                size += chunkData.getBlockEntities().length * 64; // Conservative estimate of 64 bytes per tile entity
 
-                byte[] bedrockBiome;
-                if (packet.getColumn().getBiomeData() == null) {
-                    bedrockBiome = BiomeTranslator.toBedrockBiome(session.getConnector().getWorldManager().getBiomeDataAt(session, packet.getColumn().getX(), packet.getColumn().getZ()));
-                } else {
-                    bedrockBiome = BiomeTranslator.toBedrockBiome(packet.getColumn().getBiomeData());
+                // Allocate output buffer
+                ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(size);
+                byte[] payload;
+                try {
+                    for (int i = 0; i < sectionCount; i++) {
+                        ChunkSection section = sections[i];
+                        (section != null ? section : ChunkUtils.EMPTY_SECTION).writeToNetwork(byteBuf);
+                    }
+
+                    byteBuf.writeBytes(BiomeTranslator.toBedrockBiome(mergedColumn.getBiomeData())); // Biomes - 256 bytes
+                    byteBuf.writeByte(0); // Border blocks - Edu edition only
+                    VarInts.writeUnsignedInt(byteBuf, 0); // extra data length, 0 for now
+
+                    // Encode tile entities into buffer
+                    NBTOutputStream nbtStream = NbtUtils.createNetworkWriter(new ByteBufOutputStream(byteBuf));
+                    for (NbtMap blockEntity : chunkData.getBlockEntities()) {
+                        nbtStream.writeTag(blockEntity);
+                    }
+
+                    // Copy data into byte[], because the protocol lib really likes things that are s l o w
+                    byteBuf.readBytes(payload = new byte[byteBuf.readableBytes()]);
+                } finally {
+                    byteBuf.release(); // Release buffer to allow buffer pooling to be useful
                 }
 
-                byteBuf.writeBytes(bedrockBiome); // Biomes - 256 bytes
-                byteBuf.writeByte(0); // Border blocks - Edu edition only
-                VarInts.writeUnsignedInt(byteBuf, 0); // extra data length, 0 for now
-
-                ByteBufOutputStream stream = new ByteBufOutputStream(Unpooled.buffer());
-                NBTOutputStream nbtStream = NbtUtils.createNetworkWriter(stream);
-                for (NbtMap blockEntity : chunkData.getBlockEntities()) {
-                    nbtStream.writeTag(blockEntity);
-                }
-
-                byteBuf.writeBytes(stream.buffer());
-
-                byte[] payload = new byte[byteBuf.writerIndex()];
-                byteBuf.readBytes(payload);
-
                 LevelChunkPacket levelChunkPacket = new LevelChunkPacket();
                 levelChunkPacket.setSubChunksLength(sectionCount);
                 levelChunkPacket.setCachingEnabled(false);
-                levelChunkPacket.setChunkX(packet.getColumn().getX());
-                levelChunkPacket.setChunkZ(packet.getColumn().getZ());
+                levelChunkPacket.setChunkX(mergedColumn.getX());
+                levelChunkPacket.setChunkZ(mergedColumn.getZ());
                 levelChunkPacket.setData(payload);
                 session.sendUpstreamPacket(levelChunkPacket);
-
-                session.getChunkCache().addToCache(packet.getColumn());
             } catch (Exception ex) {
                 ex.printStackTrace();
             }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
index 6972d77be..2ab3c0108 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
@@ -25,6 +25,7 @@
 
 package org.geysermc.connector.network.translators.world;
 
+import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
 import com.github.steveice10.mc.protocol.data.game.chunk.Column;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.setting.Difficulty;
@@ -48,6 +49,31 @@ public class GeyserWorldManager extends WorldManager {
         return 0;
     }
 
+    @Override
+    public void getBlocksInSection(GeyserSession session, int x, int y, int z, Chunk chunk) {
+        ChunkCache chunkCache = session.getChunkCache();
+        Column cachedColumn;
+        Chunk cachedChunk;
+        if (chunkCache == null || (cachedColumn = chunkCache.getChunk(x, z)) == null || (cachedChunk = cachedColumn.getChunks()[y]) == null) {
+            return;
+        }
+
+        // Copy state IDs from cached chunk to output chunk
+        for (int blockY = 0; blockY < 16; blockY++) { // Cache-friendly iteration order
+            for (int blockZ = 0; blockZ < 16; blockZ++) {
+                for (int blockX = 0; blockX < 16; blockX++) {
+                    chunk.set(blockX, blockY, blockZ, cachedChunk.get(blockX, blockY, blockZ));
+                }
+            }
+        }
+    }
+
+    @Override
+    public boolean hasMoreBlockDataThanChunkCache() {
+        // This implementation can only fetch data from the session chunk cache
+        return false;
+    }
+
     @Override
     public int[] getBiomeDataAt(GeyserSession session, int x, int z) {
         if (session.getConnector().getConfig().isCacheChunks()) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
index ba78b8f6f..fec3bb33a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
@@ -25,6 +25,7 @@
 
 package org.geysermc.connector.network.translators.world;
 
+import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.setting.Difficulty;
@@ -74,6 +75,27 @@ public abstract class WorldManager {
      */
     public abstract int getBlockAt(GeyserSession session, int x, int y, int z);
 
+    /**
+     * Gets all block states in the specified chunk section.
+     *
+     * @param session the session
+     * @param x the chunk's X coordinate
+     * @param y the chunk's Y coordinate
+     * @param z the chunk's Z coordinate
+     * @param section the chunk section to store the block data in
+     */
+    public abstract void getBlocksInSection(GeyserSession session, int x, int y, int z, Chunk section);
+
+    /**
+     * Checks whether or not this world manager has access to more block data than the chunk cache.
+     * <p>
+     * Some world managers (e.g. Spigot) can provide access to block data outside of the chunk cache, and even with chunk caching disabled. This
+     * method provides a means to check if this manager has this capability.
+     *
+     * @return whether or not this world manager has access to more block data than the chunk cache
+     */
+    public abstract boolean hasMoreBlockDataThanChunkCache();
+
     /**
      * Gets the biome data for the specified chunk.
      *
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/BlockStorage.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/BlockStorage.java
index 30edf1781..d8cd75206 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/BlockStorage.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/BlockStorage.java
@@ -29,14 +29,16 @@ import com.nukkitx.network.VarInts;
 import io.netty.buffer.ByteBuf;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
 import it.unimi.dsi.fastutil.ints.IntList;
+import lombok.Getter;
 import org.geysermc.connector.network.translators.world.chunk.bitarray.BitArray;
 import org.geysermc.connector.network.translators.world.chunk.bitarray.BitArrayVersion;
 
 import java.util.function.IntConsumer;
 
+@Getter
 public class BlockStorage {
 
-    private static final int SIZE = 4096;
+    public static final int SIZE = 4096;
 
     private final IntList palette;
     private BitArray bitArray;
@@ -46,12 +48,12 @@ public class BlockStorage {
     }
 
     public BlockStorage(BitArrayVersion version) {
-        this.bitArray = version.createPalette(SIZE);
+        this.bitArray = version.createArray(SIZE);
         this.palette = new IntArrayList(16);
         this.palette.add(0); // Air is at the start of every palette.
     }
 
-    private BlockStorage(BitArray bitArray, IntArrayList palette) {
+    public BlockStorage(BitArray bitArray, IntList palette) {
         this.palette = palette;
         this.bitArray = bitArray;
     }
@@ -64,16 +66,16 @@ public class BlockStorage {
         return BitArrayVersion.get(header >> 1, true);
     }
 
-    public synchronized int getFullBlock(int index) {
+    public int getFullBlock(int index) {
         return this.palette.getInt(this.bitArray.get(index));
     }
 
-    public synchronized void setFullBlock(int index, int runtimeId) {
+    public void setFullBlock(int index, int runtimeId) {
         int idx = this.idFor(runtimeId);
         this.bitArray.set(index, idx);
     }
 
-    public synchronized void writeToNetwork(ByteBuf buffer) {
+    public void writeToNetwork(ByteBuf buffer) {
         buffer.writeByte(getPaletteHeader(bitArray.getVersion(), true));
 
         for (int word : bitArray.getWords()) {
@@ -84,8 +86,18 @@ public class BlockStorage {
         palette.forEach((IntConsumer) id -> VarInts.writeInt(buffer, id));
     }
 
+    public int estimateNetworkSize() {
+        int size = 1; // Palette header
+        size += this.bitArray.getWords().length * 4;
+
+        // We assume that none of the VarInts will be larger than 3 bytes
+        size += 3; // Palette size
+        size += this.palette.size() * 3;
+        return size;
+    }
+
     private void onResize(BitArrayVersion version) {
-        BitArray newBitArray = version.createPalette(SIZE);
+        BitArray newBitArray = version.createArray(SIZE);
 
         for (int i = 0; i < SIZE; i++) {
             newBitArray.set(i, this.bitArray.get(i));
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkPosition.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkPosition.java
deleted file mode 100644
index 9e721aa9f..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkPosition.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (c) 2019-2020 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.world.chunk;
-
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.Setter;
-
-@Getter
-@Setter
-@AllArgsConstructor
-public class ChunkPosition {
-
-    /**
-     * Packs a chunk's X and Z coordinates into a single {@code long}.
-     *
-     * @param x the X coordinate
-     * @param z the Z coordinate
-     * @return the packed coordinates
-     */
-    public static long toLong(int x, int z) {
-        return ((x & 0xFFFFFFFFL) << 32L) | (z & 0xFFFFFFFFL);
-    }
-
-    private int x;
-    private int z;
-
-    public Position getBlock(int x, int y, int z) {
-        return new Position((this.x << 4) + x, y, (this.z << 4) + z);
-    }
-
-    public Position getChunkBlock(int x, int y, int z) {
-        int chunkX = x & 15;
-        int chunkY = y & 15;
-        int chunkZ = z & 15;
-        return new Position(chunkX, chunkY, chunkZ);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-        if (obj == this)    {
-            return true;
-        } else if (obj instanceof ChunkPosition) {
-            ChunkPosition chunkPosition = (ChunkPosition) obj;
-            return this.x == chunkPosition.x && this.z == chunkPosition.z;
-        } else {
-            return false;
-        }
-    }
-
-    @Override
-    public int hashCode() {
-        return this.x * 2061811133 + this.z * 1424368303;
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkSection.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkSection.java
index 48ec88064..979b79c93 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkSection.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/ChunkSection.java
@@ -27,42 +27,19 @@ package org.geysermc.connector.network.translators.world.chunk;
 
 import com.nukkitx.network.util.Preconditions;
 import io.netty.buffer.ByteBuf;
-import lombok.Synchronized;
 
 public class ChunkSection {
 
     private static final int CHUNK_SECTION_VERSION = 8;
-    public static final int SIZE = 4096;
 
     private final BlockStorage[] storage;
-    private final NibbleArray blockLight;
-    private final NibbleArray skyLight;
 
     public ChunkSection() {
-        this(new BlockStorage[]{new BlockStorage(), new BlockStorage()}, new NibbleArray(SIZE),
-                new NibbleArray(SIZE));
+        this(new BlockStorage[]{new BlockStorage(), new BlockStorage()});
     }
 
-    public ChunkSection(BlockStorage[] blockStorage) {
-        this(blockStorage, new NibbleArray(SIZE), new NibbleArray(SIZE));
-    }
-
-    public ChunkSection(BlockStorage[] storage, byte[] blockLight, byte[] skyLight) {
-        Preconditions.checkNotNull(storage, "storage");
-        Preconditions.checkArgument(storage.length > 1, "Block storage length must be at least 2");
-        for (BlockStorage blockStorage : storage) {
-            Preconditions.checkNotNull(blockStorage, "storage");
-        }
-
+    public ChunkSection(BlockStorage[] storage) {
         this.storage = storage;
-        this.blockLight = new NibbleArray(blockLight);
-        this.skyLight = new NibbleArray(skyLight);
-    }
-
-    private ChunkSection(BlockStorage[] storage, NibbleArray blockLight, NibbleArray skyLight) {
-        this.storage = storage;
-        this.blockLight = blockLight;
-        this.skyLight = skyLight;
     }
 
     public int getFullBlock(int x, int y, int z, int layer) {
@@ -77,30 +54,6 @@ public class ChunkSection {
         this.storage[layer].setFullBlock(blockPosition(x, y, z), fullBlock);
     }
 
-    @Synchronized("skyLight")
-    public byte getSkyLight(int x, int y, int z) {
-        checkBounds(x, y, z);
-        return this.skyLight.get(blockPosition(x, y, z));
-    }
-
-    @Synchronized("skyLight")
-    public void setSkyLight(int x, int y, int z, byte val) {
-        checkBounds(x, y, z);
-        this.skyLight.set(blockPosition(x, y, z), val);
-    }
-
-    @Synchronized("blockLight")
-    public byte getBlockLight(int x, int y, int z) {
-        checkBounds(x, y, z);
-        return this.blockLight.get(blockPosition(x, y, z));
-    }
-
-    @Synchronized("blockLight")
-    public void setBlockLight(int x, int y, int z, byte val) {
-        checkBounds(x, y, z);
-        this.blockLight.set(blockPosition(x, y, z), val);
-    }
-
     public void writeToNetwork(ByteBuf buffer) {
         buffer.writeByte(CHUNK_SECTION_VERSION);
         buffer.writeByte(this.storage.length);
@@ -109,12 +62,12 @@ public class ChunkSection {
         }
     }
 
-    public NibbleArray getSkyLightArray() {
-        return skyLight;
-    }
-
-    public NibbleArray getBlockLightArray() {
-        return blockLight;
+    public int estimateNetworkSize() {
+        int size = 2; // Version + storage count
+        for (BlockStorage blockStorage : this.storage) {
+            size += blockStorage.estimateNetworkSize();
+        }
+        return size;
     }
 
     public BlockStorage[] getBlockStorageArray() {
@@ -135,7 +88,7 @@ public class ChunkSection {
         for (int i = 0; i < storage.length; i++) {
             storage[i] = this.storage[i].copy();
         }
-        return new ChunkSection(storage, skyLight.copy(), blockLight.copy());
+        return new ChunkSection(storage);
     }
 
     public static int blockPosition(int x, int y, int z) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/bitarray/BitArrayVersion.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/bitarray/BitArrayVersion.java
index 20fa849c2..47a73f7c1 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/bitarray/BitArrayVersion.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/chunk/bitarray/BitArrayVersion.java
@@ -37,6 +37,8 @@ public enum BitArrayVersion {
     V2(2, 16, V3),
     V1(1, 32, V2);
 
+    private static final BitArrayVersion[] VALUES = values();
+
     final byte bits;
     final byte entriesPerWord;
     final int maxEntryValue;
@@ -58,8 +60,14 @@ public enum BitArrayVersion {
         throw new IllegalArgumentException("Invalid palette version: " + version);
     }
 
-    public BitArray createPalette(int size) {
-        return this.createPalette(size, new int[MathUtils.ceil((float) size / entriesPerWord)]);
+    public static BitArrayVersion forBitsCeil(int bits) {
+        for (int i = VALUES.length - 1; i >= 0; i--)  {
+            BitArrayVersion version = VALUES[i];
+            if (version.bits >= bits)   {
+                return version;
+            }
+        }
+        return null;
     }
 
     public byte getId() {
@@ -78,7 +86,11 @@ public enum BitArrayVersion {
         return next;
     }
 
-    public BitArray createPalette(int size, int[] words) {
+    public BitArray createArray(int size) {
+        return this.createArray(size, new int[MathUtils.ceil((float) size / entriesPerWord)]);
+    }
+
+    public BitArray createArray(int size, int[] words) {
         if (this == V3 || this == V5 || this == V6) {
             // Padded palettes aren't able to use bitwise operations due to their padding.
             return new PaddedBitArray(this, size, words);
diff --git a/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java b/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
index d47584e9d..a63eeb424 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
@@ -25,8 +25,10 @@
 
 package org.geysermc.connector.utils;
 
+import com.github.steveice10.mc.protocol.data.game.chunk.BitStorage;
 import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
 import com.github.steveice10.mc.protocol.data.game.chunk.Column;
+import com.github.steveice10.mc.protocol.data.game.chunk.palette.Palette;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
@@ -36,27 +38,39 @@ import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NBTOutputStream;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtUtils;
-import com.nukkitx.protocol.bedrock.packet.*;
+import com.nukkitx.protocol.bedrock.packet.LevelChunkPacket;
+import com.nukkitx.protocol.bedrock.packet.NetworkChunkPublisherUpdatePacket;
+import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntList;
 import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
-import it.unimi.dsi.fastutil.objects.ObjectArrayList;
-import lombok.Getter;
+import lombok.Data;
+import lombok.experimental.UtilityClass;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.entity.Entity;
 import org.geysermc.connector.entity.ItemFrameEntity;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
-import org.geysermc.connector.network.translators.world.block.entity.*;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
-import org.geysermc.connector.network.translators.world.chunk.ChunkPosition;
+import org.geysermc.connector.network.translators.world.block.entity.BedrockOnlyBlockEntity;
+import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator;
+import org.geysermc.connector.network.translators.world.block.entity.RequiresBlockState;
+import org.geysermc.connector.network.translators.world.chunk.BlockStorage;
 import org.geysermc.connector.network.translators.world.chunk.ChunkSection;
+import org.geysermc.connector.network.translators.world.chunk.bitarray.BitArray;
+import org.geysermc.connector.network.translators.world.chunk.bitarray.BitArrayVersion;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.List;
 
-import static org.geysermc.connector.network.translators.world.block.BlockTranslator.AIR;
-import static org.geysermc.connector.network.translators.world.block.BlockTranslator.BEDROCK_WATER_ID;
+import static org.geysermc.connector.network.translators.world.block.BlockTranslator.*;
 
+@UtilityClass
 public class ChunkUtils {
 
     /**
@@ -67,6 +81,9 @@ public class ChunkUtils {
     private static final NbtMap EMPTY_TAG = NbtMap.builder().build();
     public static final byte[] EMPTY_LEVEL_CHUNK_DATA;
 
+    public static final BlockStorage EMPTY_STORAGE = new BlockStorage();
+    public static final ChunkSection EMPTY_SECTION = new ChunkSection(new BlockStorage[]{ EMPTY_STORAGE });
+
     static {
         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
             outputStream.write(new byte[258]); // Biomes + Border Size + Extra Data Size
@@ -76,72 +93,144 @@ public class ChunkUtils {
             }
 
             EMPTY_LEVEL_CHUNK_DATA = outputStream.toByteArray();
-        }catch (IOException e) {
+        } catch (IOException e) {
             throw new AssertionError("Unable to generate empty level chunk data");
         }
     }
 
-    public static ChunkData translateToBedrock(GeyserSession session, Column column, boolean isNonFullChunk) {
-        ChunkData chunkData = new ChunkData();
-        Chunk[] chunks = column.getChunks();
-        chunkData.sections = new ChunkSection[chunks.length];
+    private static int indexYZXtoXZY(int yzx) {
+        return (yzx >> 8) | (yzx & 0x0F0) | ((yzx & 0x00F) << 8);
+    }
 
-        CompoundTag[] blockEntities = column.getTileEntities();
-        // Temporarily stores positions of BlockState values per chunk load
-        Object2IntMap<Position> blockEntityPositions = new Object2IntOpenHashMap<>();
+    public static ChunkData translateToBedrock(GeyserSession session, Column column, boolean isNonFullChunk) {
+        Chunk[] javaSections = column.getChunks();
+        ChunkSection[] sections = new ChunkSection[javaSections.length];
 
         // Temporarily stores compound tags of Bedrock-only block entities
-        ObjectArrayList<NbtMap> bedrockOnlyBlockEntities = new ObjectArrayList<>();
+        List<NbtMap> bedrockOnlyBlockEntities = Collections.emptyList();
 
-        for (int chunkY = 0; chunkY < chunks.length; chunkY++) {
-            chunkData.sections[chunkY] = new ChunkSection();
-            Chunk chunk = chunks[chunkY];
+        BitSet waterloggedPaletteIds = new BitSet();
+        BitSet pistonOrFlowerPaletteIds = new BitSet();
 
-            // Chunk is null and caching chunks is off or this isn't a non-full chunk
-            if (chunk == null && (!session.getConnector().getConfig().isCacheChunks() || !isNonFullChunk))
+        boolean worldManagerHasMoreBlockDataThanCache = session.getConnector().getWorldManager().hasMoreBlockDataThanChunkCache();
+
+        // If the received packet was a full chunk update, null sections in the chunk are guaranteed to also be null in the world manager
+        boolean shouldCheckWorldManagerOnMissingSections = isNonFullChunk && worldManagerHasMoreBlockDataThanCache;
+        Chunk temporarySection = null;
+
+        for (int sectionY = 0; sectionY < javaSections.length; sectionY++) {
+            Chunk javaSection = javaSections[sectionY];
+
+            // Section is null, the cache will not contain anything of use
+            if (javaSection == null) {
+                // The column parameter contains all data currently available from the cache. If the chunk is null and the world manager
+                // reports the ability to access more data than the cache, attempt to fetch from the world manager instead.
+                if (shouldCheckWorldManagerOnMissingSections) {
+                    // Ensure that temporary chunk is set
+                    if (temporarySection == null) {
+                        temporarySection = new Chunk();
+                    }
+
+                    // Read block data in section
+                    session.getConnector().getWorldManager().getBlocksInSection(session, column.getX(), sectionY, column.getZ(), temporarySection);
+
+                    if (temporarySection.isEmpty()) {
+                        // The world manager only contains air for the given section
+                        // We can leave temporarySection as-is to allow it to potentially be re-used for later sections
+                        continue;
+                    } else {
+                        javaSection = temporarySection;
+
+                        // Section contents have been modified, we can't re-use it
+                        temporarySection = null;
+                    }
+                } else {
+                    continue;
+                }
+            }
+
+            // No need to encode an empty section...
+            if (javaSection.isEmpty()) {
                 continue;
+            }
 
-            // If chunk is empty then no need to process
-            if (chunk != null && chunk.isEmpty())
-                continue;
+            Palette javaPalette = javaSection.getPalette();
+            IntList bedrockPalette = new IntArrayList(javaPalette.size());
+            waterloggedPaletteIds.clear();
+            pistonOrFlowerPaletteIds.clear();
 
-            ChunkSection section = chunkData.sections[chunkY];
-            for (int x = 0; x < 16; x++) {
-                for (int y = 0; y < 16; y++) {
-                    for (int z = 0; z < 16; z++) {
-                        int blockState;
-                        // If a non-full chunk, then grab the block that should be here to create a 'full' chunk
-                        if (chunk == null) {
-                            blockState = session.getConnector().getWorldManager().getBlockAt(session, (column.getX() << 4) + x, (chunkY << 4) + y, (column.getZ() << 4) + z);
-                        } else {
-                            blockState = chunk.get(x, y, z);
-                        }
-                        int id = BlockTranslator.getBedrockBlockId(blockState);
+            // Iterate through palette and convert state IDs to Bedrock, doing some additional checks as we go
+            for (int i = 0; i < javaPalette.size(); i++) {
+                int javaId = javaPalette.idToState(i);
+                bedrockPalette.add(BlockTranslator.getBedrockBlockId(javaId));
 
-                        // Check to see if the name is in BlockTranslator.getBlockEntityString, and therefore must be handled differently
-                        if (BlockTranslator.getBlockEntityString(blockState) != null) {
-                            Position pos = new ChunkPosition(column.getX(), column.getZ()).getBlock(x, (chunkY << 4) + y, z);
-                            blockEntityPositions.put(pos, blockState);
-                        }
+                if (BlockTranslator.isWaterlogged(javaId)) {
+                    waterloggedPaletteIds.set(i);
+                }
 
-                        section.getBlockStorageArray()[0].setFullBlock(ChunkSection.blockPosition(x, y, z), id);
+                // Check if block is piston or flower to see if we'll need to create additional block entities, as they're only block entities in Bedrock
+                if (BlockStateValues.getFlowerPotValues().containsKey(javaId) || BlockStateValues.getPistonValues().containsKey(javaId)) {
+                    pistonOrFlowerPaletteIds.set(i);
+                }
+            }
 
-                        // Check if block is piston or flower - only block entities in Bedrock
-                        if (BlockStateValues.getFlowerPotValues().containsKey(blockState) ||
-                                BlockStateValues.getPistonValues().containsKey(blockState)) {
-                            Position pos = new ChunkPosition(column.getX(), column.getZ()).getBlock(x, (chunkY << 4) + y, z);
-                            bedrockOnlyBlockEntities.add(BedrockOnlyBlockEntity.getTag(Vector3i.from(pos.getX(), pos.getY(), pos.getZ()), blockState));
-                        }
+            BitStorage javaData = javaSection.getStorage();
 
-                        if (BlockTranslator.isWaterlogged(blockState)) {
-                            section.getBlockStorageArray()[1].setFullBlock(ChunkSection.blockPosition(x, y, z), BEDROCK_WATER_ID);
-                        }
+            // Add Bedrock-exclusive block entities
+            // We only if the palette contained any blocks that are Bedrock-exclusive block entities to avoid iterating through the whole block data
+            // for no reason, as most sections will not contain any pistons or flower pots
+            if (!pistonOrFlowerPaletteIds.isEmpty()) {
+                bedrockOnlyBlockEntities = new ArrayList<>();
+                for (int yzx = 0; yzx < BlockStorage.SIZE; yzx++) {
+                    int paletteId = javaData.get(yzx);
+                    if (pistonOrFlowerPaletteIds.get(paletteId)) {
+                        bedrockOnlyBlockEntities.add(BedrockOnlyBlockEntity.getTag(
+                                Vector3i.from((column.getX() << 4) + (yzx & 0xF), (sectionY << 4) + ((yzx >> 8) & 0xF), (column.getZ() << 4) + ((yzx >> 4) & 0xF)),
+                                javaPalette.idToState(paletteId)
+                        ));
                     }
                 }
             }
 
+            BitArray bedrockData = BitArrayVersion.forBitsCeil(javaData.getBitsPerEntry()).createArray(BlockStorage.SIZE);
+            BlockStorage layer0 = new BlockStorage(bedrockData, bedrockPalette);
+            BlockStorage[] layers;
+
+            // Convert data array from YZX to XZY coordinate order
+            if (waterloggedPaletteIds.isEmpty()) {
+                // No blocks are waterlogged, simply convert coordinate order
+                // This could probably be optimized further...
+                for (int yzx = 0; yzx < BlockStorage.SIZE; yzx++) {
+                    bedrockData.set(indexYZXtoXZY(yzx), javaData.get(yzx));
+                }
+
+                layers = new BlockStorage[]{ layer0 };
+            } else {
+                // The section contains waterlogged blocks, we need to convert coordinate order AND generate a V1 block storage for
+                // layer 1 with palette ID 1 indicating water
+                int[] layer1Data = new int[BlockStorage.SIZE >> 5];
+                for (int yzx = 0; yzx < BlockStorage.SIZE; yzx++) {
+                    int paletteId = javaData.get(yzx);
+                    int xzy = indexYZXtoXZY(yzx);
+                    bedrockData.set(xzy, paletteId);
+
+                    if (waterloggedPaletteIds.get(paletteId)) {
+                        layer1Data[xzy >> 5] |= 1 << (xzy & 0x1F);
+                    }
+                }
+
+                // V1 palette
+                IntList layer1Palette = new IntArrayList(2);
+                layer1Palette.add(0); // Air
+                layer1Palette.add(BEDROCK_WATER_ID);
+
+                layers = new BlockStorage[]{ layer0, new BlockStorage(BitArrayVersion.V1.createArray(BlockStorage.SIZE, layer1Data), layer1Palette) };
+            }
+
+            sections[sectionY] = new ChunkSection(layers);
         }
 
+        CompoundTag[] blockEntities = column.getTileEntities();
         NbtMap[] bedrockBlockEntities = new NbtMap[blockEntities.length + bedrockOnlyBlockEntities.size()];
         int i = 0;
         while (i < blockEntities.length) {
@@ -155,7 +244,7 @@ public class ChunkUtils {
                 for (Tag subTag : tag) {
                     if (subTag instanceof StringTag) {
                         StringTag stringTag = (StringTag) subTag;
-                        if (stringTag.getValue().equals("")) {
+                        if (stringTag.getValue().isEmpty()) {
                             tagName = stringTag.getName();
                             break;
                         }
@@ -169,17 +258,25 @@ public class ChunkUtils {
             String id = BlockEntityUtils.getBedrockBlockEntityId(tagName);
             BlockEntityTranslator blockEntityTranslator = BlockEntityUtils.getBlockEntityTranslator(id);
             Position pos = new Position((int) tag.get("x").getValue(), (int) tag.get("y").getValue(), (int) tag.get("z").getValue());
-            int blockState = blockEntityPositions.getOrDefault(pos, 0);
+
+            // Get Java blockstate ID from block entity position
+            int blockState = 0;
+            Chunk section = column.getChunks()[pos.getY() >> 4];
+            if (section != null) {
+                blockState = section.get(pos.getX() & 0xF, pos.getY() & 0xF, pos.getZ() & 0xF);
+            }
+
             bedrockBlockEntities[i] = blockEntityTranslator.getBlockEntityTag(tagName, tag, blockState);
             i++;
         }
+
+        // Append Bedrock-exclusive block entities to output array
         for (NbtMap tag : bedrockOnlyBlockEntities) {
             bedrockBlockEntities[i] = tag;
             i++;
         }
 
-        chunkData.blockEntities = bedrockBlockEntities;
-        return chunkData;
+        return new ChunkData(sections, bedrockBlockEntities);
     }
 
     public static void updateChunkPosition(GeyserSession session, Vector3i position) {
@@ -277,10 +374,10 @@ public class ChunkUtils {
         }
     }
 
+    @Data
     public static final class ChunkData {
-        public ChunkSection[] sections;
+        private final ChunkSection[] sections;
 
-        @Getter
-        private NbtMap[] blockEntities = new NbtMap[0];
+        private final NbtMap[] blockEntities;
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/utils/MathUtils.java b/connector/src/main/java/org/geysermc/connector/utils/MathUtils.java
index 29dd2cc23..3ce4fea86 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/MathUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/MathUtils.java
@@ -74,4 +74,15 @@ public class MathUtils {
         }
         return (Byte) value;
     }
+
+    /**
+     * Packs a chunk's X and Z coordinates into a single {@code long}.
+     *
+     * @param x the X coordinate
+     * @param z the Z coordinate
+     * @return the packed coordinates
+     */
+    public static long chunkPositionToLong(int x, int z) {
+        return ((x & 0xFFFFFFFFL) << 32L) | (z & 0xFFFFFFFFL);
+    }
 }